diff --git a/eslint.config.mjs b/eslint.config.mjs index 413fa0d8..1d2ecafa 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -55,7 +55,8 @@ export default [ caughtErrorsIgnorePattern: '^_' } ], - 'eslint-comments/require-description': 'off' + 'eslint-comments/require-description': 'off', + '@typescript-eslint/no-invalid-void-type': 'off' } } ]; diff --git a/package-lock.json b/package-lock.json index 26b9f4de..73e259d5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,7 @@ "@dfinity/ic-management": "^6.2.0", "@dfinity/identity": "^2.3.0", "@dfinity/principal": "^2.3.0", + "@dfinity/zod-schemas": "^1.0.0", "@junobuild/admin": "^0.6.8-next-2025-07-30.2", "@junobuild/cdn": "^0.2.1-next-2025-07-30.2", "@junobuild/cli-tools": "^0.3.1-next-2025-07-30.2", @@ -660,14 +661,13 @@ } }, "node_modules/@dfinity/zod-schemas": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@dfinity/zod-schemas/-/zod-schemas-2.0.0.tgz", - "integrity": "sha512-mvgiYCwGXgT+iFdvTFWh5Da0HCsF8VIFTIsY+uQifaf4duc3+K1nb16O7+tCzFD7Vs4ZmjImCNi+lO5GqjplNA==", + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@dfinity/zod-schemas/-/zod-schemas-1.0.0.tgz", + "integrity": "sha512-5ApkpRO8hqTb7B9GH4H8FljY/r6hh3zpA/HFeeozIHieyebAzB748+4T9/oL6T7udkvlfWPMulbmjSHerm3B9A==", "license": "Apache-2.0", - "peer": true, "peerDependencies": { "@dfinity/principal": "^2.0.0", - "zod": "^4" + "zod": "^3.25" } }, "node_modules/@esbuild/aix-ppc64": { @@ -7097,9 +7097,9 @@ } }, "node_modules/zod": { - "version": "4.0.14", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.0.14.tgz", - "integrity": "sha512-nGFJTnJN6cM2v9kXL+SOBq3AtjQby3Mv5ySGFof5UGRHrRioSJ5iG680cYNjE/yWk671nROcpPj4hAS8nyLhSw==", + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "license": "MIT", "peer": true, "funding": { @@ -7503,10 +7503,9 @@ "requires": {} }, "@dfinity/zod-schemas": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@dfinity/zod-schemas/-/zod-schemas-2.0.0.tgz", - "integrity": "sha512-mvgiYCwGXgT+iFdvTFWh5Da0HCsF8VIFTIsY+uQifaf4duc3+K1nb16O7+tCzFD7Vs4ZmjImCNi+lO5GqjplNA==", - "peer": true, + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@dfinity/zod-schemas/-/zod-schemas-1.0.0.tgz", + "integrity": "sha512-5ApkpRO8hqTb7B9GH4H8FljY/r6hh3zpA/HFeeozIHieyebAzB748+4T9/oL6T7udkvlfWPMulbmjSHerm3B9A==", "requires": {} }, "@esbuild/aix-ppc64": { @@ -11449,9 +11448,9 @@ "dev": true }, "zod": { - "version": "4.0.14", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.0.14.tgz", - "integrity": "sha512-nGFJTnJN6cM2v9kXL+SOBq3AtjQby3Mv5ySGFof5UGRHrRioSJ5iG680cYNjE/yWk671nROcpPj4hAS8nyLhSw==", + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "peer": true } } diff --git a/package.json b/package.json index 220d9225..d28fb5bd 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ "@dfinity/ic-management": "^6.2.0", "@dfinity/identity": "^2.3.0", "@dfinity/principal": "^2.3.0", + "@dfinity/zod-schemas": "^1.0.0", "@junobuild/admin": "^0.6.8-next-2025-07-30.2", "@junobuild/cdn": "^0.2.1-next-2025-07-30.2", "@junobuild/cli-tools": "^0.3.1-next-2025-07-30.2", diff --git a/src/cli/env.loader.ts b/src/cli/env.loader.ts index 758ad8fa..f2e1a272 100644 --- a/src/cli/env.loader.ts +++ b/src/cli/env.loader.ts @@ -44,6 +44,7 @@ const loadEnvConfig = ({mode}: {mode: string | undefined}): JunoCliConfig => { return { projectName, - projectSettingsName: `${projectName}-cli-settings` + projectSettingsName: `${projectName}-cli-settings`, + projectStateName: `${projectName}-cli-state` }; }; diff --git a/src/commands/config.ts b/src/commands/config.ts index 21f1accb..bb7e64f8 100644 --- a/src/commands/config.ts +++ b/src/commands/config.ts @@ -1,132 +1,5 @@ -import {ICManagementCanister, LogVisibility} from '@dfinity/ic-management'; -import {Principal} from '@dfinity/principal'; -import {isNullish, nonNullish} from '@dfinity/utils'; -import { - type SatelliteParameters, - setAuthConfig, - setDatastoreConfig, - setStorageConfig -} from '@junobuild/admin'; -import type { - AuthenticationConfig, - DatastoreConfig, - ModuleSettings, - StorageConfig -} from '@junobuild/config'; -import {red} from 'kleur'; -import ora from 'ora'; -import {initAgent} from '../api/agent.api'; -import {assertConfigAndLoadSatelliteContext} from '../utils/satellite.utils'; - -type SetConfigResults = [ - PromiseSettledResult, - // eslint-disable-next-line @typescript-eslint/no-invalid-void-type - ...Array> -]; +import {config as configServices} from '../services/config/config.services'; export const config = async () => { - const {satellite, satelliteConfig} = await assertConfigAndLoadSatelliteContext(); - const {storage, authentication, datastore, settings} = satelliteConfig; - - const spinner = ora(`Configuring...`).start(); - - let results: SetConfigResults | undefined = undefined; - - try { - results = await Promise.allSettled([ - setStorageConfig({ - config: { - headers: storage?.headers ?? [], - rewrites: storage?.rewrites, - redirects: storage?.redirects, - iframe: storage?.iframe, - rawAccess: storage?.rawAccess, - maxMemorySize: storage?.maxMemorySize - }, - satellite - }), - ...(isNullish(datastore) - ? [] - : [ - setDatastoreConfig({ - config: datastore, - satellite - }) - ]), - ...(isNullish(authentication) - ? [] - : [ - setAuthConfig({ - config: authentication, - satellite - }) - ]), - ...(isNullish(settings) ? [] : [setSettings({settings, satellite})]) - ]); - } finally { - spinner.stop(); - } - - if (nonNullish(results)) { - printResults(results); - } -}; - -const printResults = (results: SetConfigResults) => { - const errors = results.filter((result) => result.status === 'rejected'); - - if (errors.length === 0) { - console.log('✅ Configuration applied.'); - return; - } - - console.log( - red(`The configuration failed with ${errors.length} error${errors.length > 1 ? 's' : ''} 😢.`) - ); - - errors.forEach((error, index) => { - console.log(`${index}:`, error.reason instanceof Error ? error.reason.message : error.reason); - }); -}; - -const setSettings = async ({ - settings, - satellite -}: { - settings: ModuleSettings; - satellite: Omit & - Required>; -}) => { - const { - freezingThreshold, - reservedCyclesLimit, - logVisibility, - heapMemoryLimit, - memoryAllocation, - computeAllocation - } = settings; - - const {satelliteId} = satellite; - - const agent = await initAgent(); - - const {updateSettings} = ICManagementCanister.create({ - agent - }); - - await updateSettings({ - canisterId: Principal.fromText(satelliteId), - settings: { - freezingThreshold, - reservedCyclesLimit, - logVisibility: isNullish(logVisibility) - ? undefined - : logVisibility === 'public' - ? LogVisibility.Public - : LogVisibility.Controllers, - wasmMemoryLimit: heapMemoryLimit, - memoryAllocation, - computeAllocation - } - }); + await configServices(); }; diff --git a/src/configs/cli.state.config.ts b/src/configs/cli.state.config.ts new file mode 100644 index 00000000..1d470fb1 --- /dev/null +++ b/src/configs/cli.state.config.ts @@ -0,0 +1,43 @@ +import {type PrincipalText} from '@dfinity/zod-schemas'; +import Conf from 'conf'; +import {ENV} from '../env'; +import { + type CliState, + type CliStateSatellite, + type CliStateSatelliteAppliedConfigHashes +} from '../types/cli.state'; + +export const getStateConfig = (): Conf => + new Conf({projectName: ENV.config.projectStateName}); + +export const getLatestAppliedConfig = ({ + satelliteId +}: { + satelliteId: PrincipalText; +}): CliStateSatelliteAppliedConfigHashes | undefined => + getStateConfig().get('satellites')?.[satelliteId]?.lastAppliedConfig; + +export const saveLastAppliedConfig = ({ + satelliteId, + lastAppliedConfig: {storage, datastore, auth, settings} +}: {satelliteId: PrincipalText} & Pick) => { + const config = getStateConfig(); + + const satellites = config.get('satellites'); + + const lastAppliedConfig = satellites?.[satelliteId]?.lastAppliedConfig; + + const updateSatellites = { + ...(satellites ?? {}), + [satelliteId]: { + lastAppliedConfig: { + storage: storage ?? lastAppliedConfig?.storage, + datastore: datastore ?? lastAppliedConfig?.datastore, + auth: auth ?? lastAppliedConfig?.auth, + settings: settings ?? lastAppliedConfig?.settings + } + } + }; + + config.set('satellites', updateSatellites); +}; diff --git a/src/constants/settings.constants.ts b/src/constants/settings.constants.ts new file mode 100644 index 00000000..33ff3ce3 --- /dev/null +++ b/src/constants/settings.constants.ts @@ -0,0 +1,7 @@ +export const DEFAULT_SATELLITE_HEAP_WASM_MEMORY_LIMIT = 1_073_741_824n; +export const DEFAULT_SATELLITE_FREEZING_THRESHOLD = 31_104_000n; + +export const DEFAULT_RESERVED_CYCLES_LIMIT = 5_000_000_000_000n; +export const DEFAULT_LOG_VISIBILITY = 'controllers'; +export const DEFAULT_MEMORY_ALLOCATION = 0n; +export const DEFAULT_COMPUTE_ALLOCATION = 0n; diff --git a/src/services/config/config.services.ts b/src/services/config/config.services.ts new file mode 100644 index 00000000..403d9aa6 --- /dev/null +++ b/src/services/config/config.services.ts @@ -0,0 +1,339 @@ +import {isNullish, nonNullish} from '@dfinity/utils'; +import { + getAuthConfig, + getDatastoreConfig, + getStorageConfig, + setAuthConfig, + setDatastoreConfig, + setStorageConfig +} from '@junobuild/admin'; +import type { + AuthenticationConfig, + DatastoreConfig, + ModuleSettings, + SatelliteConfig, + StorageConfig +} from '@junobuild/config'; +import {red} from 'kleur'; +import ora from 'ora'; +import {getLatestAppliedConfig, saveLastAppliedConfig} from '../../configs/cli.state.config'; +import { + DEFAULT_COMPUTE_ALLOCATION, + DEFAULT_LOG_VISIBILITY, + DEFAULT_MEMORY_ALLOCATION, + DEFAULT_RESERVED_CYCLES_LIMIT, + DEFAULT_SATELLITE_FREEZING_THRESHOLD, + DEFAULT_SATELLITE_HEAP_WASM_MEMORY_LIMIT +} from '../../constants/settings.constants'; +import { + type CliStateSatelliteAppliedConfigHashes, + type ConfigHash, + type SettingsHash +} from '../../types/cli.state'; +import type {SatelliteParametersWithId} from '../../types/satellite'; +import {objHash} from '../../utils/obj.utils'; +import {confirmAndExit} from '../../utils/prompt.utils'; +import {assertConfigAndLoadSatelliteContext} from '../../utils/satellite.utils'; +import {getSettings, setSettings} from './settings.services'; + +type SetConfigResults = [ + PromiseSettledResult, + PromiseSettledResult, + PromiseSettledResult, + PromiseSettledResult +]; + +export const config = async () => { + const {satellite, satelliteConfig} = await assertConfigAndLoadSatelliteContext(); + const {satelliteId} = satellite; + + const currentConfig = await loadCurrentConfig({satellite}); + const lastAppliedConfig = getLatestAppliedConfig({satelliteId}); + + const editConfig = await prepareConfig({ + currentConfig, + lastAppliedConfig, + satelliteConfig + }); + + const results = await applyConfig({satellite, editConfig}); + + saveLastAppliedConfigHashes({ + results, + settings: editConfig.settings, + satelliteId + }); + + printResults(results); +}; + +const saveLastAppliedConfigHashes = ({ + results, + settings, + satelliteId +}: {results: SetConfigResults} & Pick & + Pick) => { + const fulfilledValue = ( + index: number + ): void | StorageConfig | DatastoreConfig | AuthenticationConfig | undefined => + results[index].status === 'fulfilled' ? results[index].value : undefined; + + const storage = fulfilledValue(0); + const datastore = fulfilledValue(1); + const auth = fulfilledValue(2); + + const lastAppliedConfig: CliStateSatelliteAppliedConfigHashes = { + storage: nonNullish(storage) ? objHash(storage) : undefined, + datastore: nonNullish(datastore) ? objHash(datastore) : undefined, + auth: nonNullish(auth) ? objHash(auth) : undefined, + settings: nonNullish(settings) ? objHash(settings) : undefined + }; + + saveLastAppliedConfig({lastAppliedConfig, satelliteId}); +}; + +const printResults = (results: SetConfigResults) => { + const errors = results.filter((result) => result.status === 'rejected'); + + if (errors.length === 0) { + console.log('✅ Configuration applied.'); + return; + } + + console.log( + red(`The configuration failed with ${errors.length} error${errors.length > 1 ? 's' : ''} 😢.`) + ); + + errors.forEach((error, index) => { + console.log(`${index}:`, error.reason instanceof Error ? error.reason.message : error.reason); + }); +}; + +interface CurrentConfig { + storage: [StorageConfig, ConfigHash]; + datastore?: [DatastoreConfig, ConfigHash]; + auth?: [AuthenticationConfig, ConfigHash]; + settings: [ModuleSettings, SettingsHash]; +} + +const getCurrentConfig = async ({ + satellite +}: { + satellite: SatelliteParametersWithId; +}): Promise => { + const [storage, datastore, auth, settings] = await Promise.all([ + getStorageConfig({satellite}), + getDatastoreConfig({satellite}), + getAuthConfig({satellite}), + getSettings({satellite}) + ]); + + return { + storage: [storage, objHash(storage)], + ...(nonNullish(datastore) && {datastore: [datastore, objHash(datastore)]}), + ...(nonNullish(auth) && {auth: [auth, objHash(auth)]}), + settings: [settings, objHash(settings)] + }; +}; + +const loadCurrentConfig = async (params: { + satellite: SatelliteParametersWithId; +}): Promise => { + const spinner = ora('Loading...').start(); + + try { + return await getCurrentConfig(params); + } finally { + spinner.stop(); + } +}; + +const applyConfig = async ({ + satellite, + editConfig +}: { + satellite: SatelliteParametersWithId; + editConfig: Omit; +}): Promise => { + const spinner = ora('Configuring...').start(); + + try { + return await setConfigs({satellite, editConfig}); + } finally { + spinner.stop(); + } +}; + +const setConfigs = async ({ + satellite, + editConfig +}: { + satellite: SatelliteParametersWithId; + editConfig: Omit; +}): Promise => { + const {storage, authentication, datastore, settings} = editConfig; + + return await Promise.allSettled([ + isNullish(storage) + ? Promise.resolve() + : setStorageConfig({ + config: { + ...storage, + headers: storage.headers ?? [] + }, + satellite + }), + isNullish(datastore) + ? Promise.resolve() + : setDatastoreConfig({ + config: datastore, + satellite + }), + isNullish(authentication) + ? Promise.resolve() + : setAuthConfig({ + config: authentication, + satellite + }), + isNullish(settings) ? Promise.resolve() : setSettings({settings, satellite}) + ]); +}; + +const prepareConfig = async ({ + currentConfig, + lastAppliedConfig, + satelliteConfig +}: { + currentConfig: CurrentConfig; + lastAppliedConfig: CliStateSatelliteAppliedConfigHashes | undefined; + satelliteConfig: Omit; +}): Promise> => { + const { + storage: currentStorage, + datastore: currentDatastore, + auth: currentAuth, + settings: currentSettings + } = currentConfig; + + const isDefaultSettings = (): boolean => { + const [settings] = currentSettings; + return ( + settings.computeAllocation === DEFAULT_COMPUTE_ALLOCATION && + settings.memoryAllocation === DEFAULT_MEMORY_ALLOCATION && + settings.heapMemoryLimit === DEFAULT_SATELLITE_HEAP_WASM_MEMORY_LIMIT && + settings.freezingThreshold === DEFAULT_SATELLITE_FREEZING_THRESHOLD && + settings.reservedCyclesLimit === DEFAULT_RESERVED_CYCLES_LIMIT && + settings.logVisibility === DEFAULT_LOG_VISIBILITY + ); + }; + + const isDefaultConfig = (): boolean => { + const [storage] = currentStorage; + + if (nonNullish(storage.version)) { + return false; + } + + const datastoreVersion = currentDatastore?.[0].version; + + if (nonNullish(datastoreVersion)) { + return false; + } + + const authVersion = currentAuth?.[0].version; + + if (nonNullish(authVersion)) { + return false; + } + + return isDefaultSettings(); + }; + + const firstTime = isNullish(lastAppliedConfig); + + // If the developer runs `juno config` for the first time using the default configuration, + // there's no need to show a warning about overwriting an existing config - it's the first time + // they want to configure something. + if (firstTime && isDefaultConfig()) { + return satelliteConfig; + } + + const {storage, datastore, authentication, settings} = satelliteConfig; + + // Extend the satellite config from the juno.config with the current versions available in the backend + // Unless the config contains manually defined versions. + const extendWithVersions = (): Omit => { + const [{version: versionStorage}] = currentStorage; + const versionDatastore = currentDatastore?.[0]?.version; + const versionAuth = currentAuth?.[0]?.version; + + return { + storage: + nonNullish(storage) && isNullish(storage.version) + ? {...storage, version: versionStorage} + : storage, + datastore: + nonNullish(datastore) && isNullish(datastore.version) + ? {...datastore, version: versionDatastore} + : datastore, + authentication: + nonNullish(authentication) && isNullish(authentication.version) + ? {...authentication, version: versionAuth} + : authentication, + settings + }; + }; + + const confirmAndExtendWithVersions = async (): Promise> => { + await confirmAndExit( + 'This action will overwrite the current configuration of the Satellite. Are you sure you want to continue?' + ); + + return extendWithVersions(); + }; + + if (firstTime) { + return await confirmAndExtendWithVersions(); + } + + // Checks whether the last applied config (hashes stored in the CLI state file) + // matches the current Satellite configuration. + // If they match, there's no need to warn the developer about overwriting — + // they're just updating the same options they previously applied. + const isLastAppliedConfigCurrent = (): boolean => { + const [_, storageHash] = currentStorage; + + const { + storage: lastStorageHash, + datastore: lastDatastoreHash, + auth: lastAuthHash, + settings: lastSettingsHash + } = lastAppliedConfig; + + if (storageHash !== lastStorageHash) { + return false; + } + + const datastoreHash = currentDatastore?.[1]; + + if (datastoreHash !== lastDatastoreHash) { + return false; + } + + const authHash = currentAuth?.[1]; + + if (authHash !== lastAuthHash) { + return false; + } + + const [__, settingsHash] = currentSettings; + + return settingsHash === lastSettingsHash || (isDefaultSettings() && isNullish(settings)); + }; + + if (isLastAppliedConfigCurrent()) { + return extendWithVersions(); + } + + return await confirmAndExtendWithVersions(); +}; diff --git a/src/services/config/settings.services.ts b/src/services/config/settings.services.ts new file mode 100644 index 00000000..1c435bd7 --- /dev/null +++ b/src/services/config/settings.services.ts @@ -0,0 +1,81 @@ +import {ICManagementCanister, LogVisibility} from '@dfinity/ic-management'; +import {Principal} from '@dfinity/principal'; +import {isNullish} from '@dfinity/utils'; +import type {ModuleSettings} from '@junobuild/config'; +import {initAgent} from '../../api/agent.api'; +import type {SatelliteParametersWithId} from '../../types/satellite'; + +export const getSettings = async ({ + satellite +}: { + satellite: SatelliteParametersWithId; +}): Promise => { + const {satelliteId} = satellite; + + const agent = await initAgent(); + + const {canisterStatus} = ICManagementCanister.create({ + agent + }); + + const { + settings: { + freezing_threshold: freezingThreshold, + reserved_cycles_limit: reservedCyclesLimit, + wasm_memory_limit: heapMemoryLimit, + memory_allocation: memoryAllocation, + compute_allocation: computeAllocation, + log_visibility + } + } = await canisterStatus(Principal.fromText(satelliteId)); + + return { + freezingThreshold, + reservedCyclesLimit, + logVisibility: 'public' in log_visibility ? 'public' : 'controllers', + heapMemoryLimit, + memoryAllocation, + computeAllocation + }; +}; + +export const setSettings = async ({ + settings, + satellite +}: { + settings: ModuleSettings; + satellite: SatelliteParametersWithId; +}) => { + const { + freezingThreshold, + reservedCyclesLimit, + logVisibility, + heapMemoryLimit, + memoryAllocation, + computeAllocation + } = settings; + + const {satelliteId} = satellite; + + const agent = await initAgent(); + + const {updateSettings} = ICManagementCanister.create({ + agent + }); + + await updateSettings({ + canisterId: Principal.fromText(satelliteId), + settings: { + freezingThreshold, + reservedCyclesLimit, + logVisibility: isNullish(logVisibility) + ? undefined + : logVisibility === 'public' + ? LogVisibility.Public + : LogVisibility.Controllers, + wasmMemoryLimit: heapMemoryLimit, + memoryAllocation, + computeAllocation + } + }); +}; diff --git a/src/types/cli.env.ts b/src/types/cli.env.ts index 7988c1fa..c2338c1d 100644 --- a/src/types/cli.env.ts +++ b/src/types/cli.env.ts @@ -22,4 +22,6 @@ export interface JunoCliConfig { projectName: string | 'juno'; // eslint-disable-next-line @typescript-eslint/no-redundant-type-constituents projectSettingsName: string | 'juno-cli-settings'; + // eslint-disable-next-line @typescript-eslint/no-redundant-type-constituents + projectStateName: string | 'juno-cli-state'; } diff --git a/src/types/cli.state.ts b/src/types/cli.state.ts new file mode 100644 index 00000000..bf17c829 --- /dev/null +++ b/src/types/cli.state.ts @@ -0,0 +1,21 @@ +import {type PrincipalText} from '@dfinity/zod-schemas'; + +export type ConfigHash = string; +export type SettingsHash = ConfigHash; + +export interface CliStateSatelliteAppliedConfigHashes { + storage: ConfigHash | undefined; + datastore: ConfigHash | undefined; + auth: ConfigHash | undefined; + settings: SettingsHash | undefined; +} + +export interface CliStateSatellite { + lastAppliedConfig: CliStateSatelliteAppliedConfigHashes; +} + +export type CliStateSatellites = Record; + +export interface CliState { + satellites?: CliStateSatellites; +} diff --git a/src/utils/obj.utils.ts b/src/utils/obj.utils.ts new file mode 100644 index 00000000..65ca2349 --- /dev/null +++ b/src/utils/obj.utils.ts @@ -0,0 +1,5 @@ +import {jsonReplacer} from '@dfinity/utils'; +import {createHash} from 'node:crypto'; + +export const objHash = (obj: unknown): string => + createHash('sha256').update(JSON.stringify(obj, jsonReplacer)).digest('hex');