Skip to content
Open
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
15 changes: 15 additions & 0 deletions .github/workflows/build-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,21 @@ jobs:
APP_NAME: 'lattice-manager'
run: pnpm run e2e -- --reporter=basic

- name: Run CLI smoke tests with simulator
if: env.INTERNAL_EVENT == 'true'
working-directory: ${{ github.workspace }}
run: |
# CLI tests run after e2e tests to avoid pairing conflicts
# (simulator only supports one pairing at a time)
./packages/cli/dist/bin/gridplus.js simulator setup
./packages/cli/dist/bin/gridplus.js address
./packages/cli/dist/bin/gridplus.js address --count 3
./packages/cli/dist/bin/gridplus.js address --type btc-segwit
./packages/cli/dist/bin/gridplus.js address --type solana
./packages/cli/dist/bin/gridplus.js pubkey
./packages/cli/dist/bin/gridplus.js address --json
echo "CLI tests passed!"

- name: Show simulator logs on failure
if: failure() && env.INTERNAL_EVENT == 'true'
run: |
Expand Down
17 changes: 4 additions & 13 deletions biome.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"$schema": "https://biomejs.dev/schemas/2.2.4/schema.json",
"$schema": "https://biomejs.dev/schemas/1.9.4/schema.json",
"vcs": {
"enabled": true,
"clientKind": "git",
Expand All @@ -21,17 +21,9 @@
"noForEach": "warn"
},
"correctness": {
"noUnusedImports": {
"level": "error"
},
"noUnusedVariables": {
"level": "warn",
"fix": "none"
},
"noUnusedFunctionParameters": {
"level": "warn",
"fix": "none"
}
"noUnusedImports": "error",
"noUnusedVariables": "warn",
"noUnusedFunctionParameters": "warn"
},
"style": {
"noParameterAssign": "warn",
Expand Down Expand Up @@ -60,7 +52,6 @@
},
"files": {
"ignoreUnknown": true,
"include": ["packages/*/src/**"],
"ignore": ["**/dist/**", "**/node_modules/**", "**/coverage/**"]
},
"javascript": {
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"private": true,
"packageManager": "pnpm@10.6.2",
"scripts": {
"build": "turbo run build --filter=gridplus-sdk --filter=@gridplus/btc",
"build": "turbo run build --filter=gridplus-sdk --filter=@gridplus/btc --filter=@gridplus/cli",
"test": "turbo run test --filter=gridplus-sdk --filter=@gridplus/btc",
"test-unit": "turbo run test-unit --filter=gridplus-sdk --filter=@gridplus/btc",
"lint": "turbo run lint --filter=gridplus-sdk --filter=@gridplus/btc",
Expand Down
3 changes: 3 additions & 0 deletions packages/cli/bin/gridplus.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
#!/usr/bin/env node
import { program } from '../src/index.js';
program.parse();
41 changes: 41 additions & 0 deletions packages/cli/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
{
"name": "@gridplus/cli",
"version": "0.1.0",
"type": "module",
"description": "CLI for GridPlus SDK",
"bin": {
"gridplus": "./dist/bin/gridplus.js",
"gp": "./dist/bin/gridplus.js"
},
"scripts": {
"build": "tsup",
"lint": "biome check src bin",
"lint:fix": "biome check --write src bin",
"typecheck": "tsc --noEmit"
},
"files": [
"dist"
],
"dependencies": {
"@gridplus/btc": "workspace:*",
"@inquirer/prompts": "^7.0.0",
"bs58": "^6.0.0",
"chalk": "^5.0.0",
"commander": "^12.0.0",
"dotenv": "^16.0.0",
"gridplus-sdk": "workspace:*",
"lattice-eth2-utils": "^0.5.1",
"ora": "^8.0.0"
},
"devDependencies": {
"@biomejs/biome": "^1.9.0",
"@types/bs58": "^5.0.0",
"@types/node": "^24.10.4",
"tsup": "^8.5.0",
"typescript": "^5.9.2"
},
"license": "MIT",
"engines": {
"node": ">=20"
}
}
175 changes: 175 additions & 0 deletions packages/cli/src/commands/address.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
import { Command } from 'commander';
import bs58 from 'bs58';
import {
fetchAddress,
fetchAddressesByDerivationPath,
fetchBtcLegacyAddresses,
fetchBtcSegwitAddresses,
fetchBtcWrappedSegwitAddresses,
fetchSolanaAddresses,
setup,
} from 'gridplus-sdk';
import {
address as outputAddress,
error,
getStoredClient,
hasSession,
info,
output,
setStoredClient,
withSpinner,
} from '../lib/index.js';

export type AddressType =
| 'eth'
| 'btc-legacy'
| 'btc-segwit'
| 'btc-wrapped-segwit'
| 'solana';

export const addressCommand = new Command('address')
.description('Get addresses from your Lattice device')
.argument('[path]', "Derivation path (e.g., \"m/44'/60'/0'/0/0\" or index)")
.option(
'-t, --type <type>',
'Address type: eth|btc-legacy|btc-segwit|btc-wrapped-segwit|solana',
'eth',
)
.option('-n, --count <n>', 'Number of addresses to fetch', '1')
.option('-i, --index <n>', 'Starting index', '0')
.option('-j, --json', 'Output in JSON format')
.action(async (path, options) => {
try {
// Check if we have a saved session
if (!hasSession()) {
error('No device configured. Run "gp setup" first.');
process.exit(1);
}

// Initialize the SDK
await withSpinner('Connecting to device...', async () => {
return setup({
getStoredClient,
setStoredClient,
});
});

const addressType = options.type as AddressType;
const count = Number.parseInt(options.count, 10);
const startIndex = Number.parseInt(options.index, 10);

let addresses: string[];

// Strip "m/" prefix if present - SDK doesn't handle it
const cleanPath = (p: string) =>
p.startsWith('m/') ? p.slice(2) : p.startsWith('m') ? p.slice(1) : p;

// If a specific derivation path is provided, use it
if (path && typeof path === 'string' && !Number.isFinite(Number(path))) {
info(`Fetching address at path: ${path}`);
addresses = await withSpinner('Fetching addresses...', async () => {
return fetchAddressesByDerivationPath(cleanPath(path), {
n: count,
startPathIndex: startIndex,
});
});
} else {
// Use address type to determine which fetch function to use
const index = path ? Number.parseInt(path, 10) : startIndex;

switch (addressType) {
case 'btc-legacy':
info('Fetching Bitcoin Legacy (P2PKH) addresses...');
addresses = await withSpinner('Fetching addresses...', async () => {
return fetchBtcLegacyAddresses({
n: count,
startPathIndex: index,
});
});
break;

case 'btc-segwit':
info('Fetching Bitcoin Native SegWit (P2WPKH) addresses...');
addresses = await withSpinner('Fetching addresses...', async () => {
return fetchBtcSegwitAddresses({
n: count,
startPathIndex: index,
});
});
break;

case 'btc-wrapped-segwit':
info('Fetching Bitcoin Wrapped SegWit (P2SH-P2WPKH) addresses...');
addresses = await withSpinner('Fetching addresses...', async () => {
return fetchBtcWrappedSegwitAddresses({
n: count,
startPathIndex: index,
});
});
break;

case 'solana':
info('Fetching Solana addresses...');
addresses = await withSpinner('Fetching addresses...', async () => {
const results = await fetchSolanaAddresses({
n: count,
startPathIndex: index,
});
// Convert Buffer responses to base58 Solana addresses
return results.map((addr: unknown) => {
if (typeof addr === 'string') return addr;
if (Buffer.isBuffer(addr)) return bs58.encode(addr);
if (
addr &&
typeof addr === 'object' &&
'type' in addr &&
(addr as { type: string }).type === 'Buffer' &&
'data' in addr
) {
return bs58.encode(
Buffer.from((addr as { data: number[] }).data),
);
}
return String(addr);
});
});
break;
default:
info('Fetching Ethereum addresses...');
addresses = await withSpinner('Fetching addresses...', async () => {
const results: string[] = [];
for (let i = 0; i < count; i++) {
const addr = await fetchAddress(index + i);
results.push(addr);
}
return results;
});
break;
}
}

// Output results
if (options.json) {
output(
{
type: addressType,
count: addresses.length,
addresses,
},
'json',
);
} else {
if (addresses.length === 1) {
outputAddress(addresses[0], addressType.toUpperCase());
} else {
addresses.forEach((addr, i) => {
outputAddress(addr, `[${startIndex + i}]`);
});
}
}
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
error(`Failed to fetch addresses: ${message}`);
process.exit(1);
}
});
86 changes: 86 additions & 0 deletions packages/cli/src/commands/connect.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { Command } from 'commander';
import { connect, setup } from 'gridplus-sdk';
import {
error,
getStoredClient,
hasSession,
info,
loadSession,
output,
setStoredClient,
success,
withSpinner,
} from '../lib/index.js';

export const connectCommand = new Command('connect')
.description('Connect to a previously configured Lattice device')
.option('-j, --json', 'Output in JSON format')
.action(async (options) => {
try {
// Check if we have a saved session
if (!hasSession()) {
error('No device configured. Run "gp setup" first.');
process.exit(1);
}

const session = loadSession();
if (!session) {
error('Failed to load session data.');
process.exit(1);
}

info(`Connecting to device: ${session.deviceId}`);

// Initialize the SDK with stored credentials
const isPaired = await withSpinner('Connecting...', async () => {
// First setup the SDK state handlers
await setup({
getStoredClient,
setStoredClient,
});

// Then connect to the device
return connect(session.deviceId);
});

if (isPaired) {
success('Connected and paired!');
if (options.json) {
output(
{
status: 'connected',
paired: true,
deviceId: session.deviceId,
baseUrl: session.baseUrl,
appName: session.name,
},
'json',
);
} else {
output({
deviceId: session.deviceId,
baseUrl: session.baseUrl,
appName: session.name,
status: 'paired',
});
}
} else {
success('Connected but not paired.');
info('Run "gp pair" to pair with the device.');
if (options.json) {
output(
{
status: 'connected',
paired: false,
deviceId: session.deviceId,
},
'json',
);
}
}
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
error(`Connection failed: ${message}`);
process.exit(1);
}
});
Loading
Loading