Skip to content
Closed
Show file tree
Hide file tree
Changes from 5 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
23 changes: 21 additions & 2 deletions .github/workflows/build-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ jobs:
sleep 1
done

- name: Run SDK e2e tests with simulator
- name: Run SDK e2e tests and CLI tests with simulator
if: env.INTERNAL_EVENT == 'true'
working-directory: ${{ github.workspace }}
env:
Expand All @@ -111,7 +111,26 @@ jobs:
PAIRING_SECRET: '12345678'
ENC_PW: '12345678'
APP_NAME: 'lattice-manager'
run: pnpm run e2e -- --reporter=basic
run: |
# Run SDK e2e tests and CLI tests in parallel
pnpm run e2e -- --reporter=basic &
E2E_PID=$!

# Run CLI smoke tests
./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!"

# Wait for e2e tests to complete
wait $E2E_PID
E2E_EXIT=$?

exit $E2E_EXIT

- name: Show simulator logs on failure
if: failure() && env.INTERNAL_EVENT == 'true'
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