@0xobelisk/sui-cli 1.2.0-pre.12 → 1.2.0-pre.120
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.
- package/README.md +7 -7
- package/dist/dubhe.js +152 -51
- package/dist/dubhe.js.map +1 -1
- package/package.json +31 -19
- package/src/commands/build.ts +61 -18
- package/src/commands/call.ts +83 -83
- package/src/commands/checkBalance.ts +27 -12
- package/src/commands/convertJson.ts +84 -0
- package/src/commands/doctor.ts +1515 -0
- package/src/commands/faucet.ts +20 -10
- package/src/commands/generate.ts +61 -0
- package/src/commands/generateKey.ts +3 -2
- package/src/commands/index.ts +20 -11
- package/src/commands/info.ts +61 -0
- package/src/commands/loadMetadata.ts +68 -0
- package/src/commands/localnode.ts +22 -6
- package/src/commands/publish.ts +55 -7
- package/src/commands/query.ts +101 -101
- package/src/commands/shell.ts +208 -0
- package/src/commands/{configStore.ts → storeConfig.ts} +13 -5
- package/src/commands/switchEnv.ts +33 -0
- package/src/commands/test.ts +143 -31
- package/src/commands/upgrade.ts +46 -6
- package/src/commands/wait.ts +333 -22
- package/src/commands/watch.ts +9 -8
- package/src/dubhe.ts +12 -4
- package/src/utils/axios-downloader.ts +116 -0
- package/src/utils/callHandler.ts +118 -118
- package/src/utils/checkBalance.ts +6 -2
- package/src/utils/constants.ts +9 -0
- package/src/utils/generateAccount.ts +1 -1
- package/src/utils/index.ts +4 -3
- package/src/utils/metadataHandler.ts +17 -0
- package/src/utils/publishHandler.ts +404 -289
- package/src/utils/queryStorage.ts +141 -141
- package/src/utils/startNode.ts +115 -16
- package/src/utils/storeConfig.ts +50 -10
- package/src/utils/upgradeHandler.ts +210 -86
- package/src/utils/utils.ts +1025 -63
- package/src/commands/schemagen.ts +0 -40
package/src/utils/utils.ts
CHANGED
|
@@ -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 {
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
87
|
-
} catch (
|
|
117
|
+
return deployment.dappHubId;
|
|
118
|
+
} catch (_error) {
|
|
88
119
|
return '';
|
|
89
120
|
}
|
|
90
121
|
}
|
|
91
122
|
|
|
92
|
-
export async function
|
|
123
|
+
export async function getDubheDappHubId(network: string) {
|
|
93
124
|
const path = process.cwd();
|
|
94
125
|
const contractPath = `${path}/src/dubhe`;
|
|
95
126
|
|
|
96
|
-
|
|
97
|
-
|
|
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
|
|
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,
|
|
161
|
+
): Promise<Record<string, Component | MoveType>> {
|
|
114
162
|
const deployment = await getDeploymentJson(projectPath, network);
|
|
115
|
-
return deployment.
|
|
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
|
|
184
|
+
export async function getDappHubId(projectPath: string, network: string): Promise<string> {
|
|
137
185
|
const deployment = await getDeploymentJson(projectPath, network);
|
|
138
|
-
return deployment.
|
|
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 ?? '';
|
|
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
|
|
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
|
-
|
|
217
|
+
originalPackageId: string,
|
|
218
|
+
dappHubId: string,
|
|
151
219
|
upgradeCap: string,
|
|
152
220
|
version: number,
|
|
153
|
-
|
|
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
|
-
|
|
160
|
-
|
|
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
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
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
|
+
);
|
|
209
585
|
}
|
|
210
|
-
|
|
586
|
+
|
|
587
|
+
async function checkRpcAvailability(rpcUrl: string): Promise<boolean> {
|
|
211
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
|
+
});
|
|
692
|
+
}
|
|
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> {
|
|
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(
|
|
220
|
-
|
|
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
|
|
226
|
-
console.
|
|
227
|
-
|
|
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
|
-
|
|
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
|
-
|
|
235
|
-
|
|
814
|
+
// Re-throw the error for the caller to handle
|
|
815
|
+
throw error;
|
|
236
816
|
}
|
|
237
817
|
}
|
|
238
818
|
|
|
@@ -258,17 +838,399 @@ 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
|
|
856
|
+
});
|
|
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
|
+
});
|
|
273
1235
|
});
|
|
274
1236
|
}
|