@hashgraph/solo
Version:
An opinionated CLI tool to deploy and manage private Hedera Networks.
396 lines (393 loc) • 21.2 kB
JavaScript
// SPDX-License-Identifier: Apache-2.0
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
return c > 3 && r && Object.defineProperty(target, key, r), r;
};
var __metadata = (this && this.__metadata) || function (k, v) {
if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
};
var __param = (this && this.__param) || function (paramIndex, decorator) {
return function (target, key) { decorator(target, key, paramIndex); }
};
var FileCommand_1;
import chalk from 'chalk';
import { BaseCommand } from './base.js';
import { SoloError } from '../core/errors/solo-error.js';
import { Flags as flags } from './flags.js';
import { Listr } from 'listr2';
import * as constants from '../core/constants.js';
import { FileCreateTransaction, FileUpdateTransaction, FileAppendTransaction, FileContentsQuery, FileInfoQuery, FileId, Status, PrivateKey, } from '@hiero-ledger/sdk';
import { inject, injectable } from 'tsyringe-neo';
import { InjectTokens } from '../core/dependency-injection/inject-tokens.js';
import { patchInject } from '../core/dependency-injection/container-helper.js';
import fs from 'node:fs';
import path from 'node:path';
let FileCommand = class FileCommand extends BaseCommand {
static { FileCommand_1 = this; }
accountManager;
// Hiero's max content size per transaction
static MAX_CHUNK_SIZE = 4096;
constructor(accountManager) {
super();
this.accountManager = accountManager;
this.accountManager = patchInject(accountManager, InjectTokens.AccountManager, this.constructor.name);
}
static CREATE_FLAGS_LIST = {
required: [flags.deployment, flags.filePath],
optional: [],
};
static UPDATE_FLAGS_LIST = {
required: [flags.deployment, flags.fileId, flags.filePath],
optional: [],
};
async close() {
await this.accountManager.close();
}
/**
* Helper method to prepare initial content and determine if append is needed
* @param fileContent - The complete file content
* @param operation - The operation being performed ('create' or 'update')
* @returns Object with initialContent and needsAppend flag
*/
prepareInitialContent(fileContent, operation) {
const needsAppend = fileContent.length > FileCommand_1.MAX_CHUNK_SIZE;
let initialContent;
if (needsAppend) {
initialContent = fileContent.slice(0, FileCommand_1.MAX_CHUNK_SIZE);
this.logger.showUser(chalk.gray(` ${operation === 'create' ? 'Creating' : 'Updating'} file with first ${initialContent.length} bytes (multi-part ${operation})...`));
}
else {
initialContent = fileContent;
this.logger.showUser(chalk.gray(` ${operation === 'create' ? 'Creating' : 'Updating'} file with ${initialContent.length} bytes...`));
}
return { initialContent, needsAppend };
}
/**
* Helper method to initialize configuration and read file content
* @param argv - Command arguments
* @param requireFileId - Whether file ID is required (true for update, false for create)
* @returns Configuration context with file content
*/
async initializeFileConfig(argv, requireFileId) {
// Load configurations
await this.localConfig.load();
await this.remoteConfig.loadAndValidate(argv);
this.configManager.update(argv);
const filePath = argv[flags.filePath.name];
const deployment = argv[flags.deployment.name];
const namespace = this.remoteConfig.getNamespace();
let fileId = '';
if (requireFileId) {
fileId = argv[flags.fileId.name];
// Validate file ID format
if (!/^\d+\.\d+\.\d+$/.test(fileId)) {
throw new SoloError(`Invalid file ID format: ${fileId}. Expected format: 0.0.150`);
}
}
// Validate file exists
if (!fs.existsSync(filePath)) {
throw new SoloError(`File not found: ${filePath}`);
}
// Read file content
const fileContent = fs.readFileSync(filePath);
const fileName = path.basename(filePath);
this.logger.showUser(chalk.cyan(`File: ${fileName}`));
this.logger.showUser(chalk.cyan(`Size: ${fileContent.length} bytes`));
if (requireFileId) {
this.logger.showUser(chalk.cyan(`File ID: ${fileId}`));
}
const config = {
fileId,
filePath,
deployment,
namespace,
};
return {
config,
fileContent,
expectedSize: fileContent.length,
};
}
/**
* Helper method to load node client and treasury keys
* @param namespace - The namespace
* @param deployment - The deployment name
* @param useGenesisKeyForSystemFile - Whether to use genesis key for system file operations
* @returns The private key to use for transactions
*/
async loadClientAndKeys(namespace, deployment, useGenesisKeyForSystemFile = false) {
// Load node client
await this.accountManager.loadNodeClient(namespace, this.remoteConfig.getClusterRefs(), deployment);
// Use genesis key for system file operations if requested
if (useGenesisKeyForSystemFile) {
this.logger.showUser(chalk.cyan('Using genesis key for system file operations'));
this.logger.showUser(chalk.gray(' Genesis key can be customized via GENESIS_KEY environment variable'));
return PrivateKey.fromString(constants.GENESIS_KEY);
}
// Get treasury account keys
const treasuryKeys = await this.accountManager.getTreasuryAccountKeys(namespace, deployment);
return PrivateKey.fromString(treasuryKeys.privateKey);
}
/**
* Helper method to verify uploaded file content
* @param client - The Hiero client
* @param fileId - The file ID to verify
* @param expectedContent - The expected file content
*/
async verifyFileUpload(client, fileId, expectedContent) {
const fileIdObject = FileId.fromString(fileId);
this.logger.showUser(chalk.cyan('Querying file contents to verify upload...'));
const fileContentsQuery = new FileContentsQuery().setFileId(fileIdObject);
const retrievedContents = await fileContentsQuery.execute(client);
const uploadedSize = retrievedContents.length;
const expectedSize = expectedContent.length;
this.logger.showUser(chalk.gray(` Expected size: ${expectedSize} bytes`));
this.logger.showUser(chalk.gray(` Retrieved size: ${uploadedSize} bytes`));
if (uploadedSize !== expectedSize) {
// Check if this is a system file (0.0.101-0.0.200 range)
const fileIdParts = fileId.split('.');
const fileNumber = Number.parseInt(fileIdParts[2]);
const isSystemFile = fileNumber >= 101 && fileNumber <= 200;
let errorMessage = `File size mismatch! Expected ${expectedSize} bytes but got ${uploadedSize} bytes`;
if (isSystemFile && uploadedSize === 0) {
errorMessage = `${errorMessage}
⚠️ System File Update Failed:
File ${fileId} is a system file that appears to be immutable or requires special authorization.
Possible reasons:
1. The genesis key may not have permission to update this specific system file
2. System file ${fileId} may require network-level authorization or freeze/unfreeze operations
3. The network may be using a different genesis key than expected
Troubleshooting:
• Verify the correct genesis key using: echo $GENESIS_KEY
• Set custom genesis key: export GENESIS_KEY=<your-genesis-key>
• Check if the file requires special permissions beyond genesis key
• Consider using FileUpdateTransaction with additional authorization in custom code`;
}
throw new SoloError(errorMessage);
}
// Also verify content matches
const contentsMatch = Buffer.from(retrievedContents).equals(Buffer.from(expectedContent));
if (!contentsMatch) {
throw new SoloError('File content verification failed! Retrieved content does not match uploaded content');
}
this.logger.showUser(chalk.green('✓ File verification successful'));
this.logger.showUser(chalk.green(`✓ Size: ${uploadedSize} bytes`));
this.logger.showUser(chalk.green('✓ Content matches uploaded file'));
}
/**
* Helper method to append remaining file chunks after initial create/update
* @param task - The Listr task wrapper for updating progress
* @param client - The Hiero client
* @param fileId - The file ID to append to
* @param fileContent - The complete file content
* @param treasuryPrivateKey - The private key to sign transactions
*/
async appendFileChunks(task, client, fileId, fileContent, treasuryPrivateKey) {
const fileIdObject = FileId.fromString(fileId);
let offset = FileCommand_1.MAX_CHUNK_SIZE;
let chunkIndex = 1;
// Calculate total chunks needed
const totalChunks = Math.ceil((fileContent.length - FileCommand_1.MAX_CHUNK_SIZE) / FileCommand_1.MAX_CHUNK_SIZE);
while (offset < fileContent.length) {
const chunk = fileContent.slice(offset, offset + FileCommand_1.MAX_CHUNK_SIZE);
const remaining = fileContent.length - offset;
// Update task title to show progress
task.title = `Append remaining file content (chunk ${chunkIndex}/${totalChunks})`;
this.logger.showUser(chalk.gray(` Appending chunk ${chunkIndex}/${totalChunks} (${chunk.length} bytes, ${remaining} bytes remaining)...`));
const fileAppendTx = new FileAppendTransaction()
.setFileId(fileIdObject)
.setContents(chunk)
.setMaxTransactionFee(100)
.freezeWith(client);
const signedAppendTx = await fileAppendTx.sign(treasuryPrivateKey);
const appendResponse = await signedAppendTx.execute(client);
const appendReceipt = await appendResponse.getReceipt(client);
if (appendReceipt.status !== Status.Success) {
throw new SoloError(`File append (chunk ${chunkIndex}) failed with status: ${appendReceipt.status.toString()}`);
}
offset += FileCommand_1.MAX_CHUNK_SIZE;
chunkIndex++;
}
// Update final title
task.title = `Append remaining file content (${totalChunks} chunks completed)`;
this.logger.showUser(chalk.green(`✓ Appended ${totalChunks} chunks successfully`));
}
/**
* Unified method to create or update a file on the Hiero network
* @param argv - Command arguments
* @param isCreate - True for create operation, false for update
*/
async executeFileOperation(argv, isCreate) {
const tasks = new Listr([
{
title: 'Initialize configuration',
task: async (context_) => {
const result = await this.initializeFileConfig(argv, !isCreate);
context_.config = result.config;
context_.fileContent = result.fileContent;
context_.expectedSize = result.expectedSize;
// Check if this is a system file (for update operations)
if (!isCreate && context_.config.fileId) {
const fileIdParts = context_.config.fileId.split('.');
const fileNumber = Number.parseInt(fileIdParts[2]);
context_.isSystemFile = fileNumber >= 101 && fileNumber <= 200;
}
},
},
{
title: 'Load node client and treasury keys',
task: async (context_) => {
const useGenesisKey = context_.isSystemFile || false;
context_.treasuryPrivateKey = await this.loadClientAndKeys(context_.config.namespace, context_.config.deployment, useGenesisKey);
},
},
{
title: 'Check if file exists',
skip: () => isCreate, // Skip for create operation
task: async (context_) => {
const client = this.accountManager._nodeClient;
try {
const fileIdObject = FileId.fromString(context_.config.fileId);
const fileInfoQuery = new FileInfoQuery().setFileId(fileIdObject);
const fileInfo = await fileInfoQuery.execute(client);
context_.fileExists = true;
this.logger.showUser(chalk.green(`File ${context_.config.fileId} exists. Proceeding with update.`));
this.logger.showUser(chalk.gray(` Current size: ${fileInfo.size.toString()} bytes`));
const keysCount = fileInfo.keys ? fileInfo.keys.toArray().length : 0;
this.logger.showUser(chalk.gray(` Keys: ${keysCount}`));
// Check if file is a system file (no keys = immutable)
if (keysCount === 0) {
if (context_.isSystemFile) {
this.logger.showUser(chalk.cyan(`ℹ️ File ${context_.config.fileId} is a system file (no keys). Automatically using genesis key for update.`));
}
else {
this.logger.showUser(chalk.yellow(`⚠️ Warning: File ${context_.config.fileId} has no keys but is not in system file range (0.0.101-0.0.200).`));
this.logger.showUser(chalk.yellow(' Update may fail. Set GENESIS_KEY environment variable if this file requires genesis key authorization.'));
}
}
}
catch (error) {
const error_ = error.status === Status.FileDeleted || error.status === Status.InvalidFileId
? new SoloError(`File ${context_.config.fileId} does not exist. Use 'ledger file create' to create a new file.`)
: new SoloError(`Failed to query file info: ${error.message}`, error);
throw error_;
}
},
},
{
title: isCreate ? 'Create file on Hiero network' : 'Update file on Hiero network',
task: async (context_, task) => {
const client = this.accountManager._nodeClient;
const subTasks = [
{
title: isCreate ? 'Create new file' : 'Update existing file',
task: async (context__, task) => {
const { initialContent, needsAppend } = this.prepareInitialContent(context__.fileContent, isCreate ? 'create' : 'update');
if (isCreate) {
// Create new file
const fileCreateTx = new FileCreateTransaction()
.setKeys([context__.treasuryPrivateKey.publicKey])
.setContents(initialContent)
.setMaxTransactionFee(100)
.freezeWith(client);
const signedTx = await fileCreateTx.sign(context__.treasuryPrivateKey);
const txResponse = await signedTx.execute(client);
const receipt = await txResponse.getReceipt(client);
if (receipt.status !== Status.Success) {
throw new SoloError(`File creation failed with status: ${receipt.status.toString()}`);
}
const createdFileId = receipt.fileId;
context__.createdFileId = createdFileId?.toString();
context__.config.fileId = context__.createdFileId; // Update config with actual file ID
this.logger.showUser(chalk.green(`✓ File created with ID: ${context__.createdFileId}`));
}
else {
// Update existing file
const fileIdObject = FileId.fromString(context__.config.fileId);
const fileUpdateTx = new FileUpdateTransaction()
.setFileId(fileIdObject)
.setContents(initialContent)
.setMaxTransactionFee(100)
.freezeWith(client);
const signedUpdateTx = await fileUpdateTx.sign(context__.treasuryPrivateKey);
const updateResponse = await signedUpdateTx.execute(client);
const updateReceipt = await updateResponse.getReceipt(client);
if (updateReceipt.status !== Status.Success) {
throw new SoloError(`File update failed with status: ${updateReceipt.status.toString()}`);
}
this.logger.showUser(chalk.green('✓ File updated successfully'));
}
// Append remaining content if needed
if (needsAppend) {
const appendSubtasks = [
{
title: 'Append remaining file content',
task: async (context__, appendTask) => {
await this.appendFileChunks(appendTask, client, context__.config.fileId, context__.fileContent, context__.treasuryPrivateKey);
},
},
];
return task.newListr(appendSubtasks, {
concurrent: false,
rendererOptions: { collapseSubtasks: false },
});
}
},
},
];
// Create or update file
return task.newListr(subTasks, {
concurrent: false,
rendererOptions: {
collapseSubtasks: false,
},
});
},
},
{
title: 'Verify uploaded file',
task: async (context_) => {
const client = this.accountManager._nodeClient;
await this.verifyFileUpload(client, context_.config.fileId, context_.fileContent);
},
},
], constants.LISTR_DEFAULT_OPTIONS.DEFAULT);
try {
await tasks.run();
const context = tasks.ctx;
if (isCreate) {
this.logger.showUser(chalk.green('\n✅ File created successfully!'));
this.logger.showUser(chalk.cyan(`📄 File ID: ${context.createdFileId}`));
}
else {
this.logger.showUser(chalk.green('\n✅ File updated successfully!'));
}
}
catch (error) {
const operation = isCreate ? 'creation' : 'update';
throw new SoloError(`File ${operation} failed: ${error.message}`, error);
}
return true;
}
/**
* Create a new file on the Hiero network
*/
async create(argv) {
return this.executeFileOperation(argv, true);
}
/**
* Update an existing file on the Hiero network
*/
async update(argv) {
return this.executeFileOperation(argv, false);
}
};
FileCommand = FileCommand_1 = __decorate([
injectable(),
__param(0, inject(InjectTokens.AccountManager)),
__metadata("design:paramtypes", [Function])
], FileCommand);
export { FileCommand };
//# sourceMappingURL=file.js.map