@0xobelisk/sui-cli 1.2.0-pre.12 → 1.2.0-pre.121

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.
Files changed (40) hide show
  1. package/README.md +7 -7
  2. package/dist/dubhe.js +152 -51
  3. package/dist/dubhe.js.map +1 -1
  4. package/package.json +31 -19
  5. package/src/commands/build.ts +61 -18
  6. package/src/commands/call.ts +83 -83
  7. package/src/commands/checkBalance.ts +27 -12
  8. package/src/commands/convertJson.ts +85 -0
  9. package/src/commands/doctor.ts +1515 -0
  10. package/src/commands/faucet.ts +20 -10
  11. package/src/commands/generate.ts +61 -0
  12. package/src/commands/generateKey.ts +3 -2
  13. package/src/commands/index.ts +20 -11
  14. package/src/commands/info.ts +61 -0
  15. package/src/commands/loadMetadata.ts +68 -0
  16. package/src/commands/localnode.ts +22 -6
  17. package/src/commands/publish.ts +55 -7
  18. package/src/commands/query.ts +101 -101
  19. package/src/commands/shell.ts +208 -0
  20. package/src/commands/{configStore.ts → storeConfig.ts} +13 -5
  21. package/src/commands/switchEnv.ts +33 -0
  22. package/src/commands/test.ts +143 -31
  23. package/src/commands/upgrade.ts +46 -6
  24. package/src/commands/wait.ts +333 -22
  25. package/src/commands/watch.ts +9 -8
  26. package/src/dubhe.ts +12 -4
  27. package/src/utils/axios-downloader.ts +116 -0
  28. package/src/utils/callHandler.ts +118 -118
  29. package/src/utils/checkBalance.ts +6 -2
  30. package/src/utils/constants.ts +9 -0
  31. package/src/utils/generateAccount.ts +1 -1
  32. package/src/utils/index.ts +4 -3
  33. package/src/utils/metadataHandler.ts +17 -0
  34. package/src/utils/publishHandler.ts +408 -289
  35. package/src/utils/queryStorage.ts +141 -141
  36. package/src/utils/startNode.ts +115 -16
  37. package/src/utils/storeConfig.ts +50 -10
  38. package/src/utils/upgradeHandler.ts +218 -85
  39. package/src/utils/utils.ts +1041 -63
  40. package/src/commands/schemagen.ts +0 -40
@@ -1,23 +1,51 @@
1
1
  import * as fsAsync from 'fs/promises';
2
2
  import { mkdirSync, writeFileSync } from 'fs';
3
- import { dirname } from 'path';
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 } 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 packageJson from '../../package.json';
18
+ import { Component, MoveType, DubheConfig } from '@0xobelisk/sui-common';
12
19
 
13
20
  export type DeploymentJsonType = {
14
21
  projectName: string;
15
22
  network: 'mainnet' | 'testnet' | 'devnet' | 'localnet';
23
+ startCheckpoint: string;
16
24
  packageId: string;
17
- schemaId: 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;
18
45
  upgradeCap: string;
19
46
  version: number;
20
- schemas: Record<string, string>;
47
+ resources: Record<string, Component | MoveType>;
48
+ enums?: Record<string, string[]>;
21
49
  };
22
50
 
23
51
  export function validatePrivateKey(privateKey: string): false | string {
@@ -76,43 +104,63 @@ export async function getDeploymentJson(
76
104
  }
77
105
  }
78
106
 
79
- export async function getDeploymentSchemaId(projectPath: string, network: string): Promise<string> {
107
+ export async function getDeploymentDappHubId(
108
+ projectPath: string,
109
+ network: string
110
+ ): Promise<string> {
80
111
  try {
81
112
  const data = await fsAsync.readFile(
82
113
  `${projectPath}/.history/sui_${network}/latest.json`,
83
114
  'utf8'
84
115
  );
85
116
  const deployment = JSON.parse(data) as DeploymentJsonType;
86
- return deployment.schemaId;
87
- } catch (error) {
117
+ return deployment.dappHubId;
118
+ } catch (_error) {
88
119
  return '';
89
120
  }
90
121
  }
91
122
 
92
- export async function getDubheSchemaId(network: string) {
123
+ export async function getDubheDappHubId(network: string) {
93
124
  const path = process.cwd();
94
125
  const contractPath = `${path}/src/dubhe`;
95
126
 
96
- switch (network) {
97
- case 'mainnet':
98
- return await getDeploymentSchemaId(contractPath, 'mainnet');
99
- case 'testnet':
100
- return await getDeploymentSchemaId(contractPath, 'testnet');
101
- case 'devnet':
102
- return await getDeploymentSchemaId(contractPath, 'devnet');
103
- case 'localnet':
104
- return await getDeploymentSchemaId(contractPath, 'localnet');
105
- default:
106
- throw new Error(`Invalid network: ${network}`);
127
+ if (network === 'localnet') {
128
+ return await getDeploymentDappHubId(contractPath, 'localnet');
107
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
+ );
137
+ }
138
+ return config.dappHubId;
108
139
  }
109
140
 
110
- export async function getOnchainSchemas(
141
+ export async function getOriginalDubhePackageId(network: string) {
142
+ const path = process.cwd();
143
+ const contractPath = `${path}/src/dubhe`;
144
+
145
+ if (network === 'localnet') {
146
+ return await getOldPackageId(contractPath, network);
147
+ }
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
+ }
158
+ export async function getOnchainResources(
111
159
  projectPath: string,
112
160
  network: string
113
- ): Promise<Record<string, string>> {
161
+ ): Promise<Record<string, Component | MoveType>> {
114
162
  const deployment = await getDeploymentJson(projectPath, network);
115
- return deployment.schemas;
163
+ return deployment.resources;
116
164
  }
117
165
 
118
166
  export async function getVersion(projectPath: string, network: string): Promise<number> {
@@ -133,9 +181,22 @@ export async function getOldPackageId(projectPath: string, network: string): Pro
133
181
  return deployment.packageId;
134
182
  }
135
183
 
136
- export async function getSchemaId(projectPath: string, network: string): Promise<string> {
184
+ export async function getDappHubId(projectPath: string, network: string): Promise<string> {
185
+ const deployment = await getDeploymentJson(projectPath, network);
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> {
137
198
  const deployment = await getDeploymentJson(projectPath, network);
138
- return deployment.schemaId;
199
+ return deployment.dappStorageId ?? '';
139
200
  }
140
201
 
141
202
  export async function getUpgradeCap(projectPath: string, network: string): Promise<string> {
@@ -143,34 +204,78 @@ export async function getUpgradeCap(projectPath: string, network: string): Promi
143
204
  return deployment.upgradeCap;
144
205
  }
145
206
 
146
- export function saveContractData(
207
+ export async function getStartCheckpoint(projectPath: string, network: string): Promise<string> {
208
+ const deployment = await getDeploymentJson(projectPath, network);
209
+ return deployment.startCheckpoint;
210
+ }
211
+
212
+ export async function saveContractData(
147
213
  projectName: string,
148
214
  network: 'mainnet' | 'testnet' | 'devnet' | 'localnet',
215
+ startCheckpoint: string,
149
216
  packageId: string,
150
- schemaId: string,
217
+ originalPackageId: string,
218
+ dappHubId: string,
151
219
  upgradeCap: string,
152
220
  version: number,
153
- schemas: Record<string, string>
221
+ resources: Record<string, Component | MoveType>,
222
+ enums?: Record<string, string[]>,
223
+ frameworkPackageId?: string,
224
+ dappStorageId?: string
154
225
  ) {
155
226
  const DeploymentData: DeploymentJsonType = {
156
227
  projectName,
157
228
  network,
229
+ startCheckpoint,
158
230
  packageId,
159
- schemaId,
160
- schemas,
231
+ originalPackageId,
232
+ dappHubId,
233
+ frameworkPackageId,
234
+ dappStorageId,
161
235
  upgradeCap,
162
- version
236
+ version,
237
+ resources,
238
+ enums
163
239
  };
164
240
 
165
241
  const path = process.cwd();
166
242
  const storeDeploymentData = JSON.stringify(DeploymentData, null, 2);
167
- writeOutput(
243
+ await writeOutput(
168
244
  storeDeploymentData,
169
245
  `${path}/src/${projectName}/.history/sui_${network}/latest.json`,
170
246
  'Update deploy log'
171
247
  );
172
248
  }
173
249
 
250
+ export async function saveMetadata(
251
+ projectName: string,
252
+ network: 'mainnet' | 'testnet' | 'devnet' | 'localnet',
253
+ packageId: string,
254
+ fullnodeUrls?: string[]
255
+ ) {
256
+ const path = process.cwd();
257
+
258
+ // Save metadata files
259
+ try {
260
+ const metadata = await loadMetadata(network, packageId, fullnodeUrls);
261
+ if (metadata) {
262
+ const metadataJson = JSON.stringify(metadata, null, 2);
263
+
264
+ // Save packageId-specific metadata file
265
+ await writeOutput(
266
+ metadataJson,
267
+ `${path}/src/${projectName}/.history/sui_${network}/${packageId}.json`,
268
+ 'Save package metadata'
269
+ );
270
+
271
+ // Save latest metadata.json
272
+ await writeOutput(metadataJson, `${path}/metadata.json`, 'Save latest metadata');
273
+ }
274
+ } catch (error) {
275
+ console.warn(chalk.yellow(`Warning: Failed to save metadata: ${error}`));
276
+ }
277
+ }
278
+
174
279
  export async function writeOutput(
175
280
  output: string,
176
281
  fullOutputPath: string,
@@ -184,55 +289,530 @@ export async function writeOutput(
184
289
  }
185
290
  }
186
291
 
187
- function getDubheDependency(network: 'mainnet' | 'testnet' | 'devnet' | 'localnet'): string {
188
- switch (network) {
189
- case 'localnet':
190
- return 'Dubhe = { local = "../dubhe" }';
191
- case 'testnet':
192
- return `Dubhe = { git = "https://github.com/0xobelisk/dubhe-wip.git", subdir = "packages/sui-framework/contracts/dubhe", rev = "${packageJson.version}" }`;
193
- case 'mainnet':
194
- return `Dubhe = { git = "https://github.com/0xobelisk/dubhe-wip.git", subdir = "packages/sui-framework/src/dubhe", rev = "${packageJson.version}" }`;
195
- default:
196
- throw new Error(`Unsupported network: ${network}`);
197
- }
198
- }
199
-
200
292
  export async function updateDubheDependency(
201
293
  filePath: string,
202
294
  network: 'mainnet' | 'testnet' | 'devnet' | 'localnet'
203
295
  ) {
204
- const fileContent = fs.readFileSync(filePath, 'utf-8');
205
- const newDependency = getDubheDependency(network);
206
- const updatedContent = fileContent.replace(/Dubhe = \{.*\}/, newDependency);
207
- fs.writeFileSync(filePath, updatedContent, 'utf-8');
208
- console.log(`Updated Dubhe dependency in ${filePath} for ${network}.`);
296
+ // With the new --build-env mechanism, we keep Dubhe as local dependency for all networks.
297
+ // The Published.toml in ../dubhe resolves the correct on-chain address per environment.
298
+ // This function is kept for backward compatibility but is a no-op for non-localnet.
299
+ if (network === 'localnet') {
300
+ const fileContent = fs.readFileSync(filePath, 'utf-8');
301
+ const localDependency = 'Dubhe = { local = "../dubhe" }';
302
+ if (!fileContent.includes(localDependency)) {
303
+ const updatedContent = fileContent.replace(/Dubhe = \{[^}]*\}/, localDependency);
304
+ fs.writeFileSync(filePath, updatedContent, 'utf-8');
305
+ console.log(`Ensured local Dubhe dependency in ${filePath} for localnet.`);
306
+ }
307
+ }
308
+ }
309
+
310
+ // Published.toml management for the new Sui CLI (v1.44+) publishing mechanism.
311
+ // Published.toml tracks on-chain package addresses per environment.
312
+ // It SHOULD be committed to source control.
313
+
314
+ interface PublishedEntry {
315
+ chainId: string;
316
+ publishedAt: string;
317
+ originalId: string;
318
+ version: number;
319
+ }
320
+
321
+ export function readPublishedToml(packagePath: string): Record<string, PublishedEntry> {
322
+ const filePath = pathJoin(packagePath, 'Published.toml');
323
+ if (!fs.existsSync(filePath)) {
324
+ return {};
325
+ }
326
+ const content = fs.readFileSync(filePath, 'utf-8');
327
+ const result: Record<string, PublishedEntry> = {};
328
+
329
+ const sectionRegex = /\[published\.(\w+)\]([\s\S]*?)(?=\[published\.|$)/g;
330
+ let match;
331
+ while ((match = sectionRegex.exec(content)) !== null) {
332
+ const env = match[1];
333
+ const body = match[2];
334
+ const getValue = (key: string) => {
335
+ const m = body.match(new RegExp(`${key}\\s*=\\s*"([^"]*)"`));
336
+ return m ? m[1] : '';
337
+ };
338
+ const versionMatch = body.match(/version\s*=\s*(\d+)/);
339
+ result[env] = {
340
+ chainId: getValue('chain-id'),
341
+ publishedAt: getValue('published-at'),
342
+ originalId: getValue('original-id'),
343
+ version: versionMatch ? parseInt(versionMatch[1], 10) : 1
344
+ };
345
+ }
346
+ return result;
347
+ }
348
+
349
+ export function writePublishedToml(
350
+ packagePath: string,
351
+ entries: Record<string, PublishedEntry>
352
+ ): void {
353
+ const filePath = pathJoin(packagePath, 'Published.toml');
354
+ let content =
355
+ '# Generated by Move\n' +
356
+ '# This file contains metadata about published versions of this package in different environments\n' +
357
+ '# This file SHOULD be committed to source control\n';
358
+
359
+ for (const [env, entry] of Object.entries(entries)) {
360
+ content += `\n[published.${env}]\n`;
361
+ content += `chain-id = "${entry.chainId}"\n`;
362
+ content += `published-at = "${entry.publishedAt}"\n`;
363
+ content += `original-id = "${entry.originalId}"\n`;
364
+ content += `version = ${entry.version}\n`;
365
+ }
366
+
367
+ fs.writeFileSync(filePath, content, 'utf-8');
368
+ }
369
+
370
+ export function updatePublishedToml(
371
+ packagePath: string,
372
+ network: 'mainnet' | 'testnet' | 'devnet' | 'localnet',
373
+ chainId: string,
374
+ packageId: string,
375
+ originalId?: string,
376
+ version?: number
377
+ ): void {
378
+ const entries = readPublishedToml(packagePath);
379
+ const existing = entries[network];
380
+
381
+ entries[network] = {
382
+ chainId,
383
+ publishedAt: packageId,
384
+ originalId: originalId ?? existing?.originalId ?? packageId,
385
+ version: version ?? (existing ? existing.version + 1 : 1)
386
+ };
387
+
388
+ writePublishedToml(packagePath, entries);
389
+ console.log(`Updated Published.toml in ${packagePath} for ${network}.`);
390
+ }
391
+
392
+ export function getPublishedTomlEntry(
393
+ packagePath: string,
394
+ network: string
395
+ ): PublishedEntry | undefined {
396
+ const entries = readPublishedToml(packagePath);
397
+ return entries[network];
398
+ }
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
+
440
+ export function clearPublishedTomlEntry(
441
+ packagePath: string,
442
+ network: string
443
+ ): PublishedEntry | undefined {
444
+ const entries = readPublishedToml(packagePath);
445
+ const existing = entries[network];
446
+ if (!existing) return undefined;
447
+
448
+ entries[network] = {
449
+ ...existing,
450
+ publishedAt: '0x0000000000000000000000000000000000000000000000000000000000000000',
451
+ originalId: '0x0000000000000000000000000000000000000000000000000000000000000000'
452
+ };
453
+ writePublishedToml(packagePath, entries);
454
+ return existing;
455
+ }
456
+
457
+ export function restorePublishedTomlEntry(
458
+ packagePath: string,
459
+ network: string,
460
+ entry: PublishedEntry
461
+ ): void {
462
+ const entries = readPublishedToml(packagePath);
463
+ entries[network] = entry;
464
+ writePublishedToml(packagePath, entries);
465
+ }
466
+
467
+ // ─────────────────────────────────────────────────────────────────────────────
468
+ // Ephemeral publication file (Pub.<env>.toml)
469
+ //
470
+ // Per the Sui package management docs (v1.63+), localnet / devnet deployments
471
+ // should use ephemeral publication files rather than the shared Published.toml.
472
+ // The ephemeral file holds the localnet addresses so that subsequent builds
473
+ // (e.g. for upgrades) can resolve local dependencies correctly.
474
+ //
475
+ // Reference: https://docs.sui.io/guides/developer/packages/move-package-management
476
+ // ─────────────────────────────────────────────────────────────────────────────
477
+
478
+ export interface EphemeralPubEntry {
479
+ /** Absolute path to the package source directory */
480
+ source: string;
481
+ /** Current on-chain address of the package */
482
+ publishedAt: string;
483
+ /** Address of the first published version (same as publishedAt for v1) */
484
+ originalId: string;
485
+ /** Object ID of the upgrade capability */
486
+ upgradeCap: string;
487
+ /** Package version (required by Sui CLI parser) */
488
+ version?: number;
489
+ }
490
+
491
+ /**
492
+ * Return the canonical path for the ephemeral publication file.
493
+ * For localnet this is <contractsDir>/Pub.localnet.toml.
494
+ */
495
+ export function getEphemeralPubFilePath(contractsDir: string, network: string): string {
496
+ return pathJoin(contractsDir, `Pub.${network}.toml`);
497
+ }
498
+
499
+ /**
500
+ * Update (or create) an entry in the ephemeral publication file.
501
+ * Preserves existing entries for other packages.
502
+ */
503
+ export function updateEphemeralPubFile(
504
+ pubfilePath: string,
505
+ chainId: string,
506
+ buildEnv: string,
507
+ entry: EphemeralPubEntry
508
+ ): void {
509
+ const existing: EphemeralPubEntry[] = [];
510
+ // Always use the provided buildEnv and chainId parameters.
511
+ // The chainId passed in comes from the live network and is authoritative.
512
+ const currentBuildEnv = buildEnv;
513
+ const currentChainId = chainId;
514
+
515
+ if (fs.existsSync(pubfilePath)) {
516
+ const content = fs.readFileSync(pubfilePath, 'utf-8');
517
+
518
+ // Check if the file was written for a different chain (e.g. previous localnet run).
519
+ // If chain-id changed, discard all existing entries — they belong to a dead chain.
520
+ const chainIdMatch = content.match(/^chain-id\s*=\s*"([^"]*)"/m);
521
+ const fileChainId = chainIdMatch ? chainIdMatch[1] : '';
522
+ const chainChanged = fileChainId !== '' && fileChainId !== chainId;
523
+
524
+ if (!chainChanged) {
525
+ // Same chain: parse existing [[published]] blocks and preserve them.
526
+ // source field is an inline table: source = { local = "..." }
527
+ const blockRegex = /\[\[published\]\]([\s\S]*?)(?=\[\[published\]\]|$)/g;
528
+ let blockMatch;
529
+ while ((blockMatch = blockRegex.exec(content)) !== null) {
530
+ const block = blockMatch[1];
531
+ const get = (key: string) => {
532
+ const m = block.match(new RegExp(`^${key}\\s*=\\s*"([^"]*)"`, 'm'));
533
+ return m ? m[1] : '';
534
+ };
535
+ // source = { local = "/path/to/package" }
536
+ const srcMatch = block.match(/^source\s*=\s*\{\s*local\s*=\s*"([^"]*)"\s*\}/m);
537
+ const src = srcMatch ? srcMatch[1] : '';
538
+ if (src) {
539
+ existing.push({
540
+ source: src,
541
+ publishedAt: get('published-at'),
542
+ originalId: get('original-id'),
543
+ upgradeCap: get('upgrade-cap')
544
+ });
545
+ }
546
+ }
547
+ } else {
548
+ console.log(` Pub file chain-id changed (${fileChainId} → ${chainId}), resetting entries.`);
549
+ }
550
+ }
551
+
552
+ // Update override or add the entry
553
+ const idx = existing.findIndex((e) => e.source === entry.source);
554
+ if (idx >= 0) {
555
+ existing[idx] = entry;
556
+ } else {
557
+ existing.push(entry);
558
+ }
559
+
560
+ // Write the file
561
+ let content =
562
+ '# generated by dubhe cli\n' +
563
+ '# this file contains metadata from ephemeral publications\n' +
564
+ '# this file should NOT be committed to source control\n\n';
565
+ content += `build-env = "${currentBuildEnv}"\n`;
566
+ content += `chain-id = "${currentChainId}"\n`;
567
+
568
+ for (const e of existing) {
569
+ content += '\n[[published]]\n';
570
+ // source must be a LocalDepInfo struct (not a plain string)
571
+ content += `source = { local = "${e.source}" }\n`;
572
+ content += `published-at = "${e.publishedAt}"\n`;
573
+ content += `original-id = "${e.originalId}"\n`;
574
+ content += `upgrade-cap = "${e.upgradeCap}"\n`;
575
+ // version is required by Sui CLI parser (even though docs omit it)
576
+ content += `version = 1\n`;
577
+ }
578
+
579
+ fs.writeFileSync(pubfilePath, content, 'utf-8');
580
+ console.log(
581
+ ` Updated ${pathJoin(pubfilePath.split('/').slice(-1)[0])} for ${
582
+ entry.source.split('/').slice(-1)[0]
583
+ }.`
584
+ );
585
+ }
586
+
587
+ async function checkRpcAvailability(rpcUrl: string): Promise<boolean> {
588
+ try {
589
+ const response = await fetch(rpcUrl, {
590
+ method: 'POST',
591
+ headers: {
592
+ 'Content-Type': 'application/json'
593
+ },
594
+ body: JSON.stringify({
595
+ jsonrpc: '2.0',
596
+ id: 1,
597
+ method: 'sui_getLatestCheckpointSequenceNumber',
598
+ params: []
599
+ })
600
+ });
601
+
602
+ if (!response.ok) {
603
+ return false;
604
+ }
605
+
606
+ const data = await response.json();
607
+ return !data.error;
608
+ } catch (_error) {
609
+ return false;
610
+ }
611
+ }
612
+
613
+ export async function addEnv(
614
+ network: 'mainnet' | 'testnet' | 'devnet' | 'localnet',
615
+ rpcUrl?: string
616
+ ): Promise<void> {
617
+ const rpcMap = {
618
+ localnet: 'http://127.0.0.1:9000',
619
+ devnet: 'https://fullnode.devnet.sui.io:443/',
620
+ testnet: 'https://fullnode.testnet.sui.io:443/',
621
+ mainnet: 'https://fullnode.mainnet.sui.io:443/'
622
+ };
623
+
624
+ const resolvedRpcUrl = rpcUrl || rpcMap[network];
625
+
626
+ // Check RPC availability first
627
+ const isRpcAvailable = await checkRpcAvailability(resolvedRpcUrl);
628
+ if (!isRpcAvailable) {
629
+ throw new Error(
630
+ `RPC endpoint ${resolvedRpcUrl} is not available. Please check your network connection or try again later.`
631
+ );
632
+ }
633
+
634
+ return new Promise<void>((resolve, reject) => {
635
+ let errorOutput = '';
636
+ let stdoutOutput = '';
637
+
638
+ const suiProcess = spawn(
639
+ 'sui',
640
+ ['client', 'new-env', '--alias', network, '--rpc', resolvedRpcUrl],
641
+ {
642
+ env: { ...process.env },
643
+ stdio: 'pipe'
644
+ }
645
+ );
646
+
647
+ // Capture standard output
648
+ suiProcess.stdout.on('data', (data) => {
649
+ stdoutOutput += data.toString();
650
+ });
651
+
652
+ // Capture error output
653
+ suiProcess.stderr.on('data', (data) => {
654
+ errorOutput += data.toString();
655
+ });
656
+
657
+ // Handle process errors (e.g., command not found)
658
+ suiProcess.on('error', (error) => {
659
+ console.error(chalk.red(`\n❌ Failed to execute sui command: ${error.message}`));
660
+ reject(new Error(`Failed to execute sui command: ${error.message}`));
661
+ });
662
+
663
+ // Handle process exit
664
+ suiProcess.on('exit', (code, signal) => {
665
+ // Check if "already exists" message is present
666
+ if (errorOutput.includes('already exists') || stdoutOutput.includes('already exists')) {
667
+ console.log(chalk.yellow(`Environment ${network} already exists, proceeding...`));
668
+ resolve();
669
+ return;
670
+ }
671
+
672
+ if (code === 0) {
673
+ console.log(chalk.green(`Successfully added environment ${network}`));
674
+ resolve();
675
+ } else {
676
+ let finalError: string;
677
+ if (code === null) {
678
+ // Process was killed by a signal
679
+ finalError =
680
+ errorOutput ||
681
+ stdoutOutput ||
682
+ `Process was terminated by signal ${signal || 'unknown'}`;
683
+ } else {
684
+ finalError = errorOutput || stdoutOutput || `Process exited with code ${code}`;
685
+ }
686
+ console.error(chalk.red(`\n❌ Failed to add environment ${network}`));
687
+ console.error(chalk.red(` └─ ${finalError.trim()}`));
688
+ reject(new Error(finalError));
689
+ }
690
+ });
691
+ });
209
692
  }
210
- export async function switchEnv(network: 'mainnet' | 'testnet' | 'devnet' | 'localnet') {
693
+
694
+ export type NetworkAlias = 'testnet' | 'mainnet' | 'devnet' | 'localnet';
695
+
696
+ export interface Endpoint {
697
+ alias: NetworkAlias;
698
+ rpc: string;
699
+ ws: string | null;
700
+ basic_auth: { username: string; password: string } | null;
701
+ }
702
+
703
+ // mainly is a tuple of [endpoint list, current active alias]
704
+ export type ConfigTuple = [Endpoint[], NetworkAlias];
705
+
706
+ export async function envsJSON(): Promise<ConfigTuple> {
211
707
  try {
708
+ return new Promise<ConfigTuple>((resolve, reject) => {
709
+ let errorOutput = '';
710
+ let stdoutOutput = '';
711
+
712
+ const suiProcess = spawn('sui', ['client', 'envs', '--json'], {
713
+ env: { ...process.env },
714
+ stdio: 'pipe'
715
+ });
716
+
717
+ suiProcess.stdout.on('data', (data) => {
718
+ stdoutOutput += data.toString();
719
+ });
720
+
721
+ suiProcess.stderr.on('data', (data) => {
722
+ errorOutput += data.toString();
723
+ });
724
+
725
+ suiProcess.on('error', (error) => {
726
+ console.error(chalk.red(`\n❌ Failed to execute sui command: ${error.message}`));
727
+ reject(new Error(`Failed to execute sui command: ${error.message}`));
728
+ });
729
+
730
+ suiProcess.on('exit', (code, signal) => {
731
+ if (code === 0) {
732
+ resolve(JSON.parse(stdoutOutput) as ConfigTuple);
733
+ } else {
734
+ let finalError: string;
735
+ if (code === null) {
736
+ // Process was killed by a signal
737
+ finalError =
738
+ errorOutput ||
739
+ stdoutOutput ||
740
+ `Process was terminated by signal ${signal || 'unknown'}`;
741
+ } else {
742
+ finalError = errorOutput || stdoutOutput || `Process exited with code ${code}`;
743
+ }
744
+ console.error(chalk.red(`\n❌ Failed to get envs`));
745
+ console.error(chalk.red(` └─ ${finalError.trim()}`));
746
+ reject(new Error(finalError));
747
+ }
748
+ });
749
+ });
750
+ } catch (error) {
751
+ // Re-throw the error for the caller to handle
752
+ throw error;
753
+ }
754
+ }
755
+
756
+ export async function getDefaultNetwork(): Promise<NetworkAlias> {
757
+ const [_, currentAlias] = await envsJSON();
758
+ return currentAlias as NetworkAlias;
759
+ }
760
+
761
+ export async function switchEnv(
762
+ network: 'mainnet' | 'testnet' | 'devnet' | 'localnet',
763
+ rpcUrl?: string
764
+ ) {
765
+ try {
766
+ // First, try to add the environment
767
+ await addEnv(network, rpcUrl);
768
+
769
+ // Then switch to the specified environment
212
770
  return new Promise<void>((resolve, reject) => {
771
+ let errorOutput = '';
772
+ let stdoutOutput = '';
773
+
213
774
  const suiProcess = spawn('sui', ['client', 'switch', '--env', network], {
214
775
  env: { ...process.env },
215
776
  stdio: 'pipe'
216
777
  });
217
778
 
779
+ suiProcess.stdout.on('data', (data) => {
780
+ stdoutOutput += data.toString();
781
+ });
782
+
783
+ suiProcess.stderr.on('data', (data) => {
784
+ errorOutput += data.toString();
785
+ });
786
+
218
787
  suiProcess.on('error', (error) => {
219
- console.error(chalk.red('\n❌ Failed to Switch Env'));
220
- console.error(chalk.red(` Error: ${error.message}`));
221
- reject(error); // Reject promise on error
788
+ console.error(chalk.red(`\n❌ Failed to execute sui command: ${error.message}`));
789
+ reject(new Error(`Failed to execute sui command: ${error.message}`));
222
790
  });
223
791
 
224
- suiProcess.on('exit', (code) => {
225
- if (code !== 0) {
226
- console.error(chalk.red(`\n❌ Process exited with code: ${code}`));
227
- reject(new Error(`Process exited with code: ${code}`));
792
+ suiProcess.on('exit', (code, signal) => {
793
+ if (code === 0) {
794
+ console.log(chalk.green(`Successfully switched to environment ${network}`));
795
+ resolve();
228
796
  } else {
229
- resolve(); // Resolve promise on successful exit
797
+ let finalError: string;
798
+ if (code === null) {
799
+ // Process was killed by a signal
800
+ finalError =
801
+ errorOutput ||
802
+ stdoutOutput ||
803
+ `Process was terminated by signal ${signal || 'unknown'}`;
804
+ } else {
805
+ finalError = errorOutput || stdoutOutput || `Process exited with code ${code}`;
806
+ }
807
+ console.error(chalk.red(`\n❌ Failed to switch to environment ${network}`));
808
+ console.error(chalk.red(` └─ ${finalError.trim()}`));
809
+ reject(new Error(finalError));
230
810
  }
231
811
  });
232
812
  });
233
813
  } catch (error) {
234
- console.error(chalk.red('\n❌ Failed to Switch Env'));
235
- console.error(chalk.red(` └─ Error: ${error}`));
814
+ // Re-throw the error for the caller to handle
815
+ throw error;
236
816
  }
237
817
  }
238
818
 
@@ -258,17 +838,415 @@ export function loadKey(): string {
258
838
  export function initializeDubhe({
259
839
  network,
260
840
  packageId,
261
- metadata
841
+ metadata,
842
+ fullnodeUrls
262
843
  }: {
263
844
  network: NetworkType;
264
845
  packageId?: string;
265
846
  metadata?: SuiMoveNormalizedModules;
847
+ fullnodeUrls?: string[];
266
848
  }): Dubhe {
267
849
  const privateKey = loadKey();
268
850
  return new Dubhe({
269
851
  networkType: network,
270
852
  secretKey: privateKey,
271
853
  packageId,
272
- metadata
854
+ metadata,
855
+ fullnodeUrls
273
856
  });
274
857
  }
858
+
859
+ export function generateConfigJson(config: DubheConfig): string {
860
+ const serializeFields = (fields: Record<string, unknown> = {}) =>
861
+ Object.entries(fields).map(([fieldName, fieldType]) => ({
862
+ [fieldName]: fieldType
863
+ }));
864
+
865
+ const resources = Object.entries(config.resources ?? {}).map(([name, resource]) => {
866
+ // Simple type shorthand (e.g., counter1: 'u32') – entity-keyed by account (entity_id: String).
867
+ if (typeof resource === 'string') {
868
+ return {
869
+ [name]: {
870
+ fields: [{ entity_id: 'String' }, { value: resource }],
871
+ keys: ['entity_id'],
872
+ offchain: false
873
+ }
874
+ };
875
+ }
876
+
877
+ // Empty resource object – only the implicit entity key.
878
+ if (Object.keys(resource as object).length === 0) {
879
+ return {
880
+ [name]: {
881
+ fields: [{ entity_id: 'String' }],
882
+ keys: ['entity_id'],
883
+ offchain: false
884
+ }
885
+ };
886
+ }
887
+
888
+ const fields = (resource as any).fields || {};
889
+ const keys = (resource as any).keys || [];
890
+ const offchain = (resource as any).offchain ?? false;
891
+
892
+ // Full Component format with no explicit keys: auto-inject 'entity_id: String'.
893
+ if (keys.length === 0) {
894
+ const fieldEntries = Object.entries(fields);
895
+ const orderedFields: [string, unknown][] = [['entity_id', 'String'], ...fieldEntries];
896
+ return {
897
+ [name]: {
898
+ fields: orderedFields.map(([fieldName, fieldType]) => ({
899
+ [fieldName]: fieldType
900
+ })),
901
+ keys: ['entity_id'],
902
+ offchain: offchain
903
+ }
904
+ };
905
+ }
906
+
907
+ // Full Component format with explicit custom keys: inject 'entity_id: String' as the first
908
+ // field and first key so that key_tuple[0] (the BCS-encoded account injected by the indexer)
909
+ // maps correctly, followed by the user-defined keys.
910
+ const fieldEntries = Object.entries(fields);
911
+ const orderedFields: [string, unknown][] = [['entity_id', 'String'], ...fieldEntries];
912
+ return {
913
+ [name]: {
914
+ fields: orderedFields.map(([fieldName, fieldType]) => ({
915
+ [fieldName]: fieldType
916
+ })),
917
+ keys: ['entity_id', ...keys],
918
+ offchain: offchain
919
+ }
920
+ };
921
+ });
922
+
923
+ // handle enums
924
+ const enums = Object.entries(config.enums || {}).map(([name, enumFields]) => {
925
+ // Sort enum values by first letter
926
+ const sortedFields = enumFields.sort((a, b) => a.localeCompare(b)).map((value) => value);
927
+
928
+ return {
929
+ [name]: sortedFields
930
+ };
931
+ });
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
+
955
+ return JSON.stringify(
956
+ {
957
+ resources,
958
+ objects,
959
+ scenes,
960
+ permits,
961
+ enums
962
+ },
963
+ null,
964
+ 2
965
+ );
966
+ }
967
+
968
+ /**
969
+ * Updates the dubhe address and published-at in Move.toml file
970
+ * @param path - Directory path containing Move.toml file
971
+ * @param packageAddress - New dubhe package address to set
972
+ *
973
+ * Logic:
974
+ * - If packageAddress is "0x0": only set dubhe = "0x0", remove published-at line
975
+ * - Otherwise: set both dubhe and published-at to packageAddress
976
+ */
977
+ export function updateMoveTomlAddress(path: string, packageAddress: string) {
978
+ const moveTomlPath = `${path}/Move.toml`;
979
+ const moveTomlContent = fs.readFileSync(moveTomlPath, 'utf-8');
980
+
981
+ let updatedContent = moveTomlContent;
982
+
983
+ if (packageAddress === '0x0') {
984
+ // Case 1: Address is "0x0" - set dubhe to "0x0" and remove published-at line
985
+ updatedContent = updatedContent.replace(/dubhe\s*=\s*"[^"]*"/, `dubhe = "0x0"`);
986
+
987
+ // Remove published-at line (including the line break)
988
+ updatedContent = updatedContent.replace(/published-at\s*=\s*"[^"]*"\r?\n?/, '');
989
+ } else {
990
+ // Case 2: Address is not "0x0" - set both dubhe and published-at
991
+ updatedContent = updatedContent.replace(/dubhe\s*=\s*"[^"]*"/, `dubhe = "${packageAddress}"`);
992
+
993
+ // Check if published-at already exists
994
+ if (/published-at\s*=\s*"[^"]*"/.test(updatedContent)) {
995
+ // Replace existing published-at
996
+ updatedContent = updatedContent.replace(
997
+ /published-at\s*=\s*"[^"]*"/,
998
+ `published-at = "${packageAddress}"`
999
+ );
1000
+ } else {
1001
+ // Add published-at after [package] line if it doesn't exist
1002
+ updatedContent = updatedContent.replace(
1003
+ /(\[package\][^\n]*\n)/,
1004
+ `$1published-at = "${packageAddress}"\n`
1005
+ );
1006
+ }
1007
+ }
1008
+
1009
+ fs.writeFileSync(moveTomlPath, updatedContent, 'utf-8');
1010
+ }
1011
+
1012
+ export function updateGenesisUpgradeFunction(path: string, tables: string[]) {
1013
+ const genesisPath = `${path}/sources/codegen/genesis.move`;
1014
+ const genesisContent = fs.readFileSync(genesisPath, 'utf-8');
1015
+
1016
+ // Match the first pair of // ========================================== lines (with any content, including empty, between them)
1017
+ const separatorRegex =
1018
+ /(\/\/ ==========================================)[\s\S]*?(\/\/ ==========================================)/;
1019
+ const match = genesisContent.match(separatorRegex);
1020
+
1021
+ if (!match) {
1022
+ throw new Error('Could not find separator comments in genesis.move');
1023
+ }
1024
+
1025
+ // Generate new table registration code
1026
+ const registerTablesCode = tables
1027
+ .map((table) => ` ${table}::register_table(dapp_hub, ctx);`)
1028
+ .join('\n');
1029
+
1030
+ // Build new content, preserve separators, replace middle content
1031
+ const newContent = `${match[1]}\n${registerTablesCode}\n${match[2]}`;
1032
+
1033
+ // Replace matched content
1034
+ const updatedContent = genesisContent.replace(separatorRegex, newContent);
1035
+
1036
+ fs.writeFileSync(genesisPath, updatedContent, 'utf-8');
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
+ }