@0xobelisk/sui-cli 1.2.0-pre.98 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,25 +1,49 @@
1
1
  import * as fsAsync from 'fs/promises';
2
2
  import { mkdirSync, writeFileSync } from 'fs';
3
3
  import { dirname, join as pathJoin } from 'path';
4
+ import * as readline from 'readline';
4
5
  import { SUI_PRIVATE_KEY_PREFIX } from '@mysten/sui/cryptography';
5
6
  import { FsIibError } from './errors';
6
7
  import * as fs from 'fs';
7
8
  import chalk from 'chalk';
8
9
  import { spawn } from 'child_process';
9
- import { Dubhe, NetworkType, SuiMoveNormalizedModules, loadMetadata } from '@0xobelisk/sui-client';
10
+ import {
11
+ Dubhe,
12
+ NetworkType,
13
+ SuiMoveNormalizedModules,
14
+ loadMetadata,
15
+ getDefaultConfig
16
+ } from '@0xobelisk/sui-client';
10
17
  import { DubheCliError } from './errors';
11
- import { Component, MoveType, EmptyComponent, DubheConfig } from '@0xobelisk/sui-common';
12
- import { TESTNET_DUBHE_HUB_OBJECT_ID, TESTNET_ORIGINAL_DUBHE_PACKAGE_ID } from './constants';
18
+ import { Component, MoveType, DubheConfig } from '@0xobelisk/sui-common';
13
19
 
14
20
  export type DeploymentJsonType = {
15
21
  projectName: string;
16
22
  network: 'mainnet' | 'testnet' | 'devnet' | 'localnet';
17
23
  startCheckpoint: string;
18
24
  packageId: string;
19
- dappHub: string;
25
+ /**
26
+ * The original (first-published) package ID of this dapp.
27
+ * Derived from type_name::with_defining_ids<DappKey>() in Move, so it is stable
28
+ * across upgrades and is the canonical identifier used in dapp_key and indexer filtering.
29
+ * Set once at publish time and never changed during upgrades.
30
+ */
31
+ originalPackageId: string;
32
+ /** Object ID of the Dubhe framework's DappHub shared object. */
33
+ dappHubId: string;
34
+ /**
35
+ * Published package ID of the Dubhe framework used by this deployment.
36
+ * Populated for localnet (ephemeral deploy); undefined for testnet/mainnet
37
+ * where the SDK already knows the well-known constant.
38
+ */
39
+ frameworkPackageId?: string;
40
+ /**
41
+ * Object ID of the DappStorage shared object created by genesis::run.
42
+ * Required for calling migrate_to_vN during upgrades.
43
+ */
44
+ dappStorageId?: string;
20
45
  upgradeCap: string;
21
46
  version: number;
22
- components: Record<string, Component | MoveType | EmptyComponent>;
23
47
  resources: Record<string, Component | MoveType>;
24
48
  enums?: Record<string, string[]>;
25
49
  };
@@ -80,62 +104,57 @@ export async function getDeploymentJson(
80
104
  }
81
105
  }
82
106
 
83
- export async function getDeploymentDappHub(projectPath: string, network: string): Promise<string> {
107
+ export async function getDeploymentDappHubId(
108
+ projectPath: string,
109
+ network: string
110
+ ): Promise<string> {
84
111
  try {
85
112
  const data = await fsAsync.readFile(
86
113
  `${projectPath}/.history/sui_${network}/latest.json`,
87
114
  'utf8'
88
115
  );
89
116
  const deployment = JSON.parse(data) as DeploymentJsonType;
90
- return deployment.dappHub;
117
+ return deployment.dappHubId;
91
118
  } catch (_error) {
92
119
  return '';
93
120
  }
94
121
  }
95
122
 
96
- export async function getDubheDappHub(network: string) {
123
+ export async function getDubheDappHubId(network: string) {
97
124
  const path = process.cwd();
98
125
  const contractPath = `${path}/src/dubhe`;
99
126
 
100
- switch (network) {
101
- case 'mainnet':
102
- return TESTNET_DUBHE_HUB_OBJECT_ID;
103
- case 'testnet':
104
- return TESTNET_DUBHE_HUB_OBJECT_ID;
105
- case 'devnet':
106
- return TESTNET_DUBHE_HUB_OBJECT_ID;
107
- case 'localnet':
108
- return await getDeploymentDappHub(contractPath, 'localnet');
109
- default:
110
- throw new Error(`Invalid network: ${network}`);
127
+ if (network === 'localnet') {
128
+ return await getDeploymentDappHubId(contractPath, 'localnet');
129
+ }
130
+
131
+ const config = getDefaultConfig(network as NetworkType);
132
+ if (!config.dappHubId) {
133
+ throw new Error(
134
+ `DappHub object ID is not configured for network "${network}". ` +
135
+ `Update MAINNET_DUBHE_HUB_OBJECT_ID / TESTNET_DUBHE_HUB_OBJECT_ID in @0xobelisk/sui-client.`
136
+ );
111
137
  }
138
+ return config.dappHubId;
112
139
  }
113
140
 
114
141
  export async function getOriginalDubhePackageId(network: string) {
115
142
  const path = process.cwd();
116
143
  const contractPath = `${path}/src/dubhe`;
117
144
 
118
- switch (network) {
119
- case 'mainnet':
120
- return TESTNET_ORIGINAL_DUBHE_PACKAGE_ID;
121
- case 'testnet':
122
- return TESTNET_ORIGINAL_DUBHE_PACKAGE_ID;
123
- case 'devnet':
124
- return TESTNET_ORIGINAL_DUBHE_PACKAGE_ID;
125
- case 'localnet':
126
- return await getOldPackageId(contractPath, network);
127
- default:
128
- throw new Error(`Invalid network: ${network}`);
145
+ if (network === 'localnet') {
146
+ return await getOldPackageId(contractPath, network);
129
147
  }
130
- }
131
- export async function getOnchainComponents(
132
- projectPath: string,
133
- network: string
134
- ): Promise<Record<string, Component | MoveType | EmptyComponent>> {
135
- const deployment = await getDeploymentJson(projectPath, network);
136
- return deployment.components;
137
- }
138
148
 
149
+ const config = getDefaultConfig(network as NetworkType);
150
+ if (!config.frameworkPackageId) {
151
+ throw new Error(
152
+ `Framework package ID is not configured for network "${network}". ` +
153
+ `Update MAINNET_DUBHE_FRAMEWORK_PACKAGE_ID / TESTNET_DUBHE_FRAMEWORK_PACKAGE_ID in @0xobelisk/sui-client.`
154
+ );
155
+ }
156
+ return config.frameworkPackageId;
157
+ }
139
158
  export async function getOnchainResources(
140
159
  projectPath: string,
141
160
  network: string
@@ -162,9 +181,22 @@ export async function getOldPackageId(projectPath: string, network: string): Pro
162
181
  return deployment.packageId;
163
182
  }
164
183
 
165
- export async function getDappHub(projectPath: string, network: string): Promise<string> {
184
+ export async function getDappHubId(projectPath: string, network: string): Promise<string> {
166
185
  const deployment = await getDeploymentJson(projectPath, network);
167
- return deployment.dappHub;
186
+ return deployment.dappHubId;
187
+ }
188
+
189
+ export async function getFrameworkPackageIdFromDeployment(
190
+ projectPath: string,
191
+ network: string
192
+ ): Promise<string | undefined> {
193
+ const deployment = await getDeploymentJson(projectPath, network);
194
+ return deployment.frameworkPackageId;
195
+ }
196
+
197
+ export async function getDappStorageId(projectPath: string, network: string): Promise<string> {
198
+ const deployment = await getDeploymentJson(projectPath, network);
199
+ return deployment.dappStorageId ?? '';
168
200
  }
169
201
 
170
202
  export async function getUpgradeCap(projectPath: string, network: string): Promise<string> {
@@ -182,22 +214,26 @@ export async function saveContractData(
182
214
  network: 'mainnet' | 'testnet' | 'devnet' | 'localnet',
183
215
  startCheckpoint: string,
184
216
  packageId: string,
185
- dappHub: string,
217
+ originalPackageId: string,
218
+ dappHubId: string,
186
219
  upgradeCap: string,
187
220
  version: number,
188
- components: Record<string, Component | MoveType | EmptyComponent>,
189
221
  resources: Record<string, Component | MoveType>,
190
- enums?: Record<string, string[]>
222
+ enums?: Record<string, string[]>,
223
+ frameworkPackageId?: string,
224
+ dappStorageId?: string
191
225
  ) {
192
226
  const DeploymentData: DeploymentJsonType = {
193
227
  projectName,
194
228
  network,
195
229
  startCheckpoint,
196
230
  packageId,
197
- dappHub,
231
+ originalPackageId,
232
+ dappHubId,
233
+ frameworkPackageId,
234
+ dappStorageId,
198
235
  upgradeCap,
199
236
  version,
200
- components,
201
237
  resources,
202
238
  enums
203
239
  };
@@ -214,13 +250,14 @@ export async function saveContractData(
214
250
  export async function saveMetadata(
215
251
  projectName: string,
216
252
  network: 'mainnet' | 'testnet' | 'devnet' | 'localnet',
217
- packageId: string
253
+ packageId: string,
254
+ fullnodeUrls?: string[]
218
255
  ) {
219
256
  const path = process.cwd();
220
257
 
221
258
  // Save metadata files
222
259
  try {
223
- const metadata = await loadMetadata(network, packageId);
260
+ const metadata = await loadMetadata(network, packageId, fullnodeUrls);
224
261
  if (metadata) {
225
262
  const metadataJson = JSON.stringify(metadata, null, 2);
226
263
 
@@ -360,6 +397,46 @@ export function getPublishedTomlEntry(
360
397
  return entries[network];
361
398
  }
362
399
 
400
+ /**
401
+ * Syncs the Dubhe framework address in `src/dubhe/Published.toml` with the
402
+ * canonical package ID from the SDK's `getDefaultConfig` for the given network.
403
+ *
404
+ * This prevents `VMVerificationOrDeserializationError` during `publish` and
405
+ * `upgrade` when the framework has been redeployed on testnet/mainnet but the
406
+ * local `Published.toml` still references the old address. The function is a
407
+ * no-op for localnet and devnet (no stable canonical address exists there).
408
+ *
409
+ * @param contractsRootDir - The contracts working directory (process.cwd() in CLI context)
410
+ * @param network - Target network
411
+ * @param chainId - Live chain identifier obtained from the node
412
+ */
413
+ export function syncDubheFrameworkAddress(
414
+ contractsRootDir: string,
415
+ network: 'mainnet' | 'testnet' | 'devnet' | 'localnet',
416
+ chainId: string
417
+ ): void {
418
+ if (network === 'localnet' || network === 'devnet') return;
419
+
420
+ const frameworkPackageId = getDefaultConfig(network as NetworkType).frameworkPackageId;
421
+ if (!frameworkPackageId) return;
422
+
423
+ const dubhePath = pathJoin(contractsRootDir, 'src', 'dubhe');
424
+ if (!fs.existsSync(dubhePath)) return;
425
+
426
+ const existing = getPublishedTomlEntry(dubhePath, network);
427
+ if (existing?.publishedAt === frameworkPackageId) return;
428
+
429
+ updatePublishedToml(dubhePath, network, chainId, frameworkPackageId, frameworkPackageId, 1);
430
+ console.log(
431
+ chalk.gray(
432
+ ` ├─ Auto-synced dubhe framework address for ${network}: ${frameworkPackageId.slice(
433
+ 0,
434
+ 10
435
+ )}...`
436
+ )
437
+ );
438
+ }
439
+
363
440
  export function clearPublishedTomlEntry(
364
441
  packagePath: string,
365
442
  network: string
@@ -534,7 +611,8 @@ async function checkRpcAvailability(rpcUrl: string): Promise<boolean> {
534
611
  }
535
612
 
536
613
  export async function addEnv(
537
- network: 'mainnet' | 'testnet' | 'devnet' | 'localnet'
614
+ network: 'mainnet' | 'testnet' | 'devnet' | 'localnet',
615
+ rpcUrl?: string
538
616
  ): Promise<void> {
539
617
  const rpcMap = {
540
618
  localnet: 'http://127.0.0.1:9000',
@@ -543,13 +621,13 @@ export async function addEnv(
543
621
  mainnet: 'https://fullnode.mainnet.sui.io:443/'
544
622
  };
545
623
 
546
- const rpcUrl = rpcMap[network];
624
+ const resolvedRpcUrl = rpcUrl || rpcMap[network];
547
625
 
548
626
  // Check RPC availability first
549
- const isRpcAvailable = await checkRpcAvailability(rpcUrl);
627
+ const isRpcAvailable = await checkRpcAvailability(resolvedRpcUrl);
550
628
  if (!isRpcAvailable) {
551
629
  throw new Error(
552
- `RPC endpoint ${rpcUrl} is not available. Please check your network connection or try again later.`
630
+ `RPC endpoint ${resolvedRpcUrl} is not available. Please check your network connection or try again later.`
553
631
  );
554
632
  }
555
633
 
@@ -559,7 +637,7 @@ export async function addEnv(
559
637
 
560
638
  const suiProcess = spawn(
561
639
  'sui',
562
- ['client', 'new-env', '--alias', network, '--rpc', rpcMap[network]],
640
+ ['client', 'new-env', '--alias', network, '--rpc', resolvedRpcUrl],
563
641
  {
564
642
  env: { ...process.env },
565
643
  stdio: 'pipe'
@@ -680,10 +758,13 @@ export async function getDefaultNetwork(): Promise<NetworkAlias> {
680
758
  return currentAlias as NetworkAlias;
681
759
  }
682
760
 
683
- export async function switchEnv(network: 'mainnet' | 'testnet' | 'devnet' | 'localnet') {
761
+ export async function switchEnv(
762
+ network: 'mainnet' | 'testnet' | 'devnet' | 'localnet',
763
+ rpcUrl?: string
764
+ ) {
684
765
  try {
685
766
  // First, try to add the environment
686
- await addEnv(network);
767
+ await addEnv(network, rpcUrl);
687
768
 
688
769
  // Then switch to the specified environment
689
770
  return new Promise<void>((resolve, reject) => {
@@ -757,70 +838,31 @@ export function loadKey(): string {
757
838
  export function initializeDubhe({
758
839
  network,
759
840
  packageId,
760
- metadata
841
+ metadata,
842
+ fullnodeUrls
761
843
  }: {
762
844
  network: NetworkType;
763
845
  packageId?: string;
764
846
  metadata?: SuiMoveNormalizedModules;
847
+ fullnodeUrls?: string[];
765
848
  }): Dubhe {
766
849
  const privateKey = loadKey();
767
850
  return new Dubhe({
768
851
  networkType: network,
769
852
  secretKey: privateKey,
770
853
  packageId,
771
- metadata
854
+ metadata,
855
+ fullnodeUrls
772
856
  });
773
857
  }
774
858
 
775
859
  export function generateConfigJson(config: DubheConfig): string {
776
- const components = Object.entries(config.components).map(([name, component]) => {
777
- if (typeof component === 'string') {
778
- return {
779
- [name]: {
780
- fields: [{ entity_id: 'address' }, { value: component }],
781
- keys: ['entity_id'],
782
- offchain: false
783
- }
784
- };
785
- }
786
-
787
- if (Object.keys(component as object).length === 0) {
788
- return {
789
- [name]: {
790
- fields: [{ entity_id: 'address' }],
791
- keys: ['entity_id'],
792
- offchain: false
793
- }
794
- };
795
- }
796
-
797
- const fields = (component as any).fields || {};
798
- const keys = (component as any).keys || ['entity_id'];
799
- const offchain = (component as any).offchain ?? false;
800
-
801
- // ensure entity_id field exists
802
- if (!fields.entity_id && keys.includes('entity_id')) {
803
- fields.entity_id = 'address';
804
- }
805
-
806
- // prepare fields with entity_id first
807
- const fieldEntries = Object.entries(fields);
808
- const entityIdField = fieldEntries.find(([key]) => key === 'entity_id');
809
- const otherFields = fieldEntries.filter(([key]) => key !== 'entity_id');
810
- const orderedFields = entityIdField ? [entityIdField, ...otherFields] : otherFields;
811
-
812
- return {
813
- [name]: {
814
- fields: orderedFields.map(([fieldName, fieldType]) => ({
815
- [fieldName]: fieldType
816
- })),
817
- keys: keys,
818
- offchain: offchain
819
- }
820
- };
821
- });
860
+ const serializeFields = (fields: Record<string, unknown> = {}) =>
861
+ Object.entries(fields).map(([fieldName, fieldType]) => ({
862
+ [fieldName]: fieldType
863
+ }));
822
864
 
823
- const resources = Object.entries(config.resources).map(([name, resource]) => {
865
+ const resources = Object.entries(config.resources ?? {}).map(([name, resource]) => {
824
866
  // Simple type shorthand (e.g., counter1: 'u32') – entity-keyed by account (entity_id: String).
825
867
  if (typeof resource === 'string') {
826
868
  return {
@@ -878,25 +920,6 @@ export function generateConfigJson(config: DubheConfig): string {
878
920
  };
879
921
  });
880
922
 
881
- // Auto-append Dubhe framework fee state resource (entity-keyed by account string).
882
- if (!resources.some((resource) => 'dapp_fee_state' in resource)) {
883
- resources.push({
884
- dapp_fee_state: {
885
- fields: [
886
- { entity_id: 'String' },
887
- { base_fee: 'u256' },
888
- { byte_fee: 'u256' },
889
- { free_credit: 'u256' },
890
- { total_bytes_size: 'u256' },
891
- { total_recharged: 'u256' },
892
- { total_paid: 'u256' }
893
- ],
894
- keys: ['entity_id'],
895
- offchain: false
896
- }
897
- });
898
- }
899
-
900
923
  // handle enums
901
924
  const enums = Object.entries(config.enums || {}).map(([name, enumFields]) => {
902
925
  // Sort enum values by first letter
@@ -907,10 +930,34 @@ export function generateConfigJson(config: DubheConfig): string {
907
930
  };
908
931
  });
909
932
 
933
+ const objects = Object.entries(config.objects ?? {}).map(([name, object]) => ({
934
+ [name]: {
935
+ fields: serializeFields(object.fields),
936
+ accepts: object.accepts ?? [],
937
+ acceptsFrom: object.acceptsFrom ?? [],
938
+ adminOnly: object.adminOnly ?? false
939
+ }
940
+ }));
941
+
942
+ const scenes = Object.entries(config.scenes ?? {}).map(([name, scene]) => ({
943
+ [name]: {
944
+ fields: serializeFields(scene.fields),
945
+ authorization: scene.authorization,
946
+ accepts: scene.accepts ?? [],
947
+ acceptsFrom: scene.acceptsFrom ?? []
948
+ }
949
+ }));
950
+
951
+ const permits = Object.entries(config.permits ?? {}).map(([name, permit]) => ({
952
+ [name]: permit ?? {}
953
+ }));
954
+
910
955
  return JSON.stringify(
911
956
  {
912
- components,
913
957
  resources,
958
+ objects,
959
+ scenes,
960
+ permits,
914
961
  enums
915
962
  },
916
963
  null,
@@ -988,3 +1035,238 @@ export function updateGenesisUpgradeFunction(path: string, tables: string[]) {
988
1035
 
989
1036
  fs.writeFileSync(genesisPath, updatedContent, 'utf-8');
990
1037
  }
1038
+
1039
+ /**
1040
+ * Appends a `migrate_to_vN` entry function to the package's migrate.move and
1041
+ * bumps `ON_CHAIN_VERSION` to `newVersion`.
1042
+ *
1043
+ * Called by upgradeHandler when new resources are detected (pendingMigration.length > 0).
1044
+ * The generated function:
1045
+ * 1. Reads the new package ID via `dapp_key::package_id()` — available on the new package.
1046
+ * 2. Reads the target version via `migrate::on_chain_version()` — equals newVersion after
1047
+ * this function bumps the constant.
1048
+ * 3. Calls `dapp_system::upgrade_dapp` to register the new package ID and bump
1049
+ * `DappStorage.version`.
1050
+ * 4. Calls `genesis::migrate` for any custom migration logic (extension point).
1051
+ *
1052
+ * `upgrade_dapp` accepts the new package's DappKey because its check was changed to compare
1053
+ * the caller's package ID against the registered list OR the incoming new_package_id, rather
1054
+ * than doing a full type-string comparison that would always fail after an upgrade.
1055
+ */
1056
+ export function appendMigrateFunction(
1057
+ projectPath: string,
1058
+ packageName: string,
1059
+ newVersion: number
1060
+ ): void {
1061
+ const migratePath = `${projectPath}/sources/scripts/migrate.move`;
1062
+ if (!fs.existsSync(migratePath)) {
1063
+ throw new Error(`migrate.move not found at ${migratePath}`);
1064
+ }
1065
+
1066
+ let content = fs.readFileSync(migratePath, 'utf-8');
1067
+
1068
+ // Idempotency: skip entirely if the function already exists
1069
+ if (content.includes(`migrate_to_v${newVersion}`)) {
1070
+ return;
1071
+ }
1072
+
1073
+ // ── Step 1: bump ON_CHAIN_VERSION to newVersion ──────────────────────────────
1074
+ // Replace the first `ON_CHAIN_VERSION: u32 = <N>` constant in the file.
1075
+ // This ensures on_chain_version() returns the correct value when upgrade_dapp
1076
+ // reads it inside the generated migrate_to_vN function.
1077
+ content = content.replace(
1078
+ /const ON_CHAIN_VERSION:\s*u32\s*=\s*\d+\s*;/,
1079
+ `const ON_CHAIN_VERSION: u32 = ${newVersion};`
1080
+ );
1081
+
1082
+ // ── Step 2: append migrate_to_vN ─────────────────────────────────────────────
1083
+ // new_package_id must be passed as a parameter because type_name::get<T>() in
1084
+ // Sui Move always returns the ORIGINAL (genesis) package ID, not the upgraded one.
1085
+ // The TypeScript upgradeHandler supplies the actual new package ID after the upgrade
1086
+ // transaction completes and the on-chain package address is known.
1087
+ const migrateFunction = `
1088
+ public entry fun migrate_to_v${newVersion}(
1089
+ dapp_hub: &mut dubhe::dapp_service::DappHub,
1090
+ dapp_storage: &mut dubhe::dapp_service::DappStorage,
1091
+ new_package_id: address,
1092
+ ctx: &mut TxContext
1093
+ ) {
1094
+ let new_version = ${packageName}::migrate::on_chain_version();
1095
+ dubhe::dapp_system::upgrade_dapp<${packageName}::dapp_key::DappKey>(
1096
+ dapp_hub, dapp_storage, new_package_id, new_version, ctx
1097
+ );
1098
+ ${packageName}::genesis::migrate(dapp_hub, dapp_storage, ctx);
1099
+ }
1100
+ `;
1101
+
1102
+ // Insert the new function before the closing brace of the module
1103
+ const closingBraceIdx = content.lastIndexOf('}');
1104
+ if (closingBraceIdx === -1) {
1105
+ throw new Error(`Could not find closing brace in ${migratePath}`);
1106
+ }
1107
+
1108
+ const updated =
1109
+ content.slice(0, closingBraceIdx) + migrateFunction + content.slice(closingBraceIdx);
1110
+ fs.writeFileSync(migratePath, updated, 'utf-8');
1111
+ }
1112
+
1113
+ // ---------------------------------------------------------------------------
1114
+ // Guard lint
1115
+ // ---------------------------------------------------------------------------
1116
+
1117
+ export type MissingGuardResult = {
1118
+ /** Relative path to the Move source file (for display). */
1119
+ file: string;
1120
+ /** Name of the entry function missing the guard. */
1121
+ fn: string;
1122
+ };
1123
+
1124
+ /**
1125
+ * Scans every `*.move` file under `<projectPath>/sources/systems/` and returns
1126
+ * the list of `public entry fun` declarations that:
1127
+ * 1. Accept a `DappStorage` parameter (so a version check is applicable), AND
1128
+ * 2. Do NOT call `ensure_latest_version` anywhere in their body.
1129
+ *
1130
+ * The implementation uses brace-balancing to extract each function body rather
1131
+ * than a full AST parse, which is sufficient for this structural check.
1132
+ */
1133
+ export function lintSystemGuards(projectPath: string): MissingGuardResult[] {
1134
+ const systemsDir = pathJoin(projectPath, 'sources', 'systems');
1135
+ if (!fs.existsSync(systemsDir)) return [];
1136
+
1137
+ const results: MissingGuardResult[] = [];
1138
+ const files = fs.readdirSync(systemsDir).filter((f) => f.endsWith('.move'));
1139
+
1140
+ for (const file of files) {
1141
+ const fullPath = pathJoin(systemsDir, file);
1142
+ const src = fs.readFileSync(fullPath, 'utf-8');
1143
+
1144
+ // Find every `public entry fun <name>` position.
1145
+ const entryFunRe = /public\s+entry\s+fun\s+(\w+)\s*\(/g;
1146
+ let match: RegExpExecArray | null;
1147
+
1148
+ while ((match = entryFunRe.exec(src)) !== null) {
1149
+ const fnName = match[1];
1150
+ const parenStart = match.index + match[0].length - 1; // position of '('
1151
+
1152
+ // Extract the parameter list (between the outermost parentheses).
1153
+ let depth = 0;
1154
+ let parenEnd = parenStart;
1155
+ for (let i = parenStart; i < src.length; i++) {
1156
+ if (src[i] === '(') depth++;
1157
+ else if (src[i] === ')') {
1158
+ depth--;
1159
+ if (depth === 0) {
1160
+ parenEnd = i;
1161
+ break;
1162
+ }
1163
+ }
1164
+ }
1165
+ const paramList = src.slice(parenStart + 1, parenEnd);
1166
+
1167
+ // Only flag functions that receive a DappStorage parameter.
1168
+ if (!/DappStorage/.test(paramList)) continue;
1169
+
1170
+ // Extract the function body (between the outermost braces after the params).
1171
+ const braceStart = src.indexOf('{', parenEnd);
1172
+ if (braceStart === -1) continue;
1173
+
1174
+ depth = 0;
1175
+ let braceEnd = braceStart;
1176
+ for (let i = braceStart; i < src.length; i++) {
1177
+ if (src[i] === '{') depth++;
1178
+ else if (src[i] === '}') {
1179
+ depth--;
1180
+ if (depth === 0) {
1181
+ braceEnd = i;
1182
+ break;
1183
+ }
1184
+ }
1185
+ }
1186
+ const body = src.slice(braceStart, braceEnd + 1);
1187
+
1188
+ if (!/ensure_latest_version/.test(body)) {
1189
+ results.push({ file, fn: fnName });
1190
+ }
1191
+ }
1192
+ }
1193
+
1194
+ return results;
1195
+ }
1196
+
1197
+ /**
1198
+ * Formats lint results as a human-readable warning block.
1199
+ * Returns an empty string when there are no issues.
1200
+ */
1201
+ export function formatLintWarnings(results: MissingGuardResult[]): string {
1202
+ if (results.length === 0) return '';
1203
+ const lines: string[] = [
1204
+ chalk.yellow('⚠️ Missing ensure_latest_version in the following entry functions:'),
1205
+ chalk.yellow(' Old-package callers can still invoke these functions after an upgrade.'),
1206
+ ''
1207
+ ];
1208
+ for (const r of results) {
1209
+ lines.push(chalk.yellow(` • ${r.file} → ${r.fn}()`));
1210
+ }
1211
+ lines.push('');
1212
+ lines.push(
1213
+ chalk.yellow(
1214
+ ' Fix: add dubhe::dapp_system::ensure_latest_version(dapp_storage); at the top of each function.'
1215
+ )
1216
+ );
1217
+ lines.push('');
1218
+ return lines.join('\n');
1219
+ }
1220
+
1221
+ /**
1222
+ * Prompts the user for a yes/no confirmation on stdout/stdin.
1223
+ * Resolves `true` for "y/Y", `false` for everything else.
1224
+ */
1225
+ export function confirm(question: string): Promise<boolean> {
1226
+ return new Promise((resolve) => {
1227
+ const rl = readline.createInterface({
1228
+ input: process.stdin,
1229
+ output: process.stdout
1230
+ });
1231
+ rl.question(chalk.yellow(`${question} [y/N] `), (answer: string) => {
1232
+ rl.close();
1233
+ resolve(answer.trim().toLowerCase() === 'y');
1234
+ });
1235
+ });
1236
+ }
1237
+
1238
+ /**
1239
+ * Append a new package ID to the `package_ids` array in dubhe.config.json.
1240
+ * Idempotent — does nothing if the ID is already present or the file does not exist.
1241
+ * Called by upgradeHandler after a successful on-chain upgrade so the indexer
1242
+ * can verify event.type_.address against all known package versions on next startup.
1243
+ */
1244
+ export function appendPackageIdToConfig(configJsonPath: string, newPackageId: string): void {
1245
+ if (!fs.existsSync(configJsonPath)) return;
1246
+ const configJson = JSON.parse(fs.readFileSync(configJsonPath, 'utf-8'));
1247
+ const existingIds: string[] = Array.isArray(configJson.package_ids) ? configJson.package_ids : [];
1248
+ if (!existingIds.includes(newPackageId)) {
1249
+ configJson.package_ids = [...existingIds, newPackageId];
1250
+ fs.writeFileSync(configJsonPath, JSON.stringify(configJson, null, 2));
1251
+ }
1252
+ }
1253
+
1254
+ /**
1255
+ * Strip the [env.<network>] section from a Move.lock file before a build.
1256
+ *
1257
+ * Move.lock is owned by the Sui CLI and should never be written by our CLI.
1258
+ * We only remove stale [env.*] entries so the Sui CLI does not pick up an
1259
+ * old non-zero address and fail with PublishErrorNonZeroAddress. After the
1260
+ * publish/upgrade the entry is intentionally left absent — Published.toml is
1261
+ * the canonical source of truth for deployed addresses across our toolchain.
1262
+ */
1263
+ export function removeEnvFromMoveLock(
1264
+ filePath: string,
1265
+ networkType: 'mainnet' | 'testnet' | 'devnet' | 'localnet'
1266
+ ): void {
1267
+ if (!fs.existsSync(filePath)) return;
1268
+ const content = fs.readFileSync(filePath, 'utf-8');
1269
+ const regex = new RegExp(`\\[env\\.${networkType}\\][\\s\\S]*?(?=\\[|$)`, 'g');
1270
+ const updated = content.replace(regex, '');
1271
+ fs.writeFileSync(filePath, updated, 'utf-8');
1272
+ }