@meddleware/token-deployer-ui 0.0.1
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/.claude/move-language-reference/SKILL.md +445 -0
- package/.claude/skills/move-language-reference/SKILL.md +445 -0
- package/.claude/skills/sui-move-bootcamp/SKILL.md +128 -0
- package/.claude/sui-move-bootcamp/SKILL.md +128 -0
- package/.editorconfig +8 -0
- package/.env.example +75 -0
- package/.env.production +7 -0
- package/.gitattributes +1 -0
- package/.github/workflows/ci.yml +34 -0
- package/.github/workflows/token-deployer-e2e.yml +64 -0
- package/.oxfmtrc.json +5 -0
- package/.oxlintrc.json +10 -0
- package/.vscode/extensions.json +9 -0
- package/AGENTS.md +39 -0
- package/CLAUDE.md +159 -0
- package/LICENSE +121 -0
- package/README.md +234 -0
- package/env.d.ts +13 -0
- package/index.html +13 -0
- package/package.json +67 -0
- package/playwright.config.ts +30 -0
- package/public/CNAME +1 -0
- package/public/_headers +12 -0
- package/public/_redirects +1 -0
- package/public/favicon.ico +0 -0
- package/public/favicon.svg +5 -0
- package/public/og-image.png +0 -0
- package/public/robots.txt +3 -0
- package/public/sitemap.xml +8 -0
- package/scripts/e2e-browser.mjs +15 -0
- package/scripts/e2e-deploy.mjs +222 -0
- package/scripts/e2e-localnet.mjs +65 -0
- package/scripts/e2e-walrus-browser.mjs +115 -0
- package/scripts/gen-og-image.mjs +31 -0
- package/scripts/publish-localnet.mjs +55 -0
- package/scripts/regen-template.sh +45 -0
- package/scripts/sync-template-src.sh +36 -0
- package/scripts/verify-bytecode.mjs +49 -0
- package/src/App.vue +41 -0
- package/src/__tests__/App.spec.js +11 -0
- package/src/components/AppNotice.vue +15 -0
- package/src/components/ColorModeControl.vue +81 -0
- package/src/components/ConfigForm.vue +192 -0
- package/src/components/DeployProgress.vue +46 -0
- package/src/components/FieldHint.vue +79 -0
- package/src/components/FormField.vue +28 -0
- package/src/components/FormSelect.vue +43 -0
- package/src/components/GithubPush.vue +100 -0
- package/src/components/IconPicker.vue +410 -0
- package/src/components/LicensePicker.vue +182 -0
- package/src/components/ResultPanel.vue +88 -0
- package/src/components/ReviewPanel.vue +69 -0
- package/src/components/TermsDialog.vue +81 -0
- package/src/components/TokenDeployerView.vue +115 -0
- package/src/components/WalletBar.vue +108 -0
- package/src/components/WalrusBlobBrowser.vue +188 -0
- package/src/components/WalrusIconUpload.vue +205 -0
- package/src/composables/useAccessGate.ts +122 -0
- package/src/composables/useColorMode.ts +76 -0
- package/src/composables/useWalrusRelay.ts +21 -0
- package/src/config.ts +185 -0
- package/src/index.ts +1 -0
- package/src/lib/accessGate.ts +141 -0
- package/src/lib/buildPublishTx.ts +192 -0
- package/src/lib/deploy.ts +168 -0
- package/src/lib/deployExecutor.ts +61 -0
- package/src/lib/errors.ts +21 -0
- package/src/lib/form.ts +75 -0
- package/src/lib/generatePackage.ts +312 -0
- package/src/lib/github.ts +146 -0
- package/src/lib/licenses.ts +122 -0
- package/src/lib/readClient.ts +17 -0
- package/src/lib/template.ts +131 -0
- package/src/lib/types.ts +69 -0
- package/src/lib/validation.ts +172 -0
- package/src/lib/walrus-constants.ts +15 -0
- package/src/lib/walrus.ts +144 -0
- package/src/main.ts +154 -0
- package/src/move-template/sui_token_template.mv +0 -0
- package/src/move-template/template.ts +31 -0
- package/src/styles.css +384 -0
- package/src/template-src/files.json +10 -0
- package/test-results/.last-run.json +4 -0
- package/tests/accessGate.test.ts +108 -0
- package/tests/buildPublishTx.test.ts +243 -0
- package/tests/deploy.test.ts +81 -0
- package/tests/errors.test.ts +32 -0
- package/tests/generatePackage.test.ts +182 -0
- package/tests/github.test.ts +61 -0
- package/tests/licenses.test.ts +78 -0
- package/tests/template.test.ts +93 -0
- package/tests/templateArtifact.test.ts +61 -0
- package/tests/templateParity.test.ts +55 -0
- package/tests/useAccessGate.test.ts +101 -0
- package/tests/useWallet.test.ts +74 -0
- package/tests/useWalrusRelayGating.test.ts +30 -0
- package/tests/validation.test.ts +117 -0
- package/tests/walrusUpload.test.ts +93 -0
- package/tests/walrus_url_patch.test.ts +26 -0
- package/tsconfig.json +19 -0
- package/typedoc.json +34 -0
- package/vite.config.ts +65 -0
- package/vitest.config.ts +10 -0
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
// Client-side generator for the downloadable source package. Mirrors the CLI
|
|
2
|
+
// generator (blockchain/sui/sui-token-template/scripts/03_create_token.sh) so the
|
|
3
|
+
// zip a user downloads matches what the CLI would produce (and the on-chain
|
|
4
|
+
// bytecode). Template text is synced verbatim via scripts/sync-template-src.sh.
|
|
5
|
+
//
|
|
6
|
+
// Published.toml: since Sui v1.63, the CLI writes deployed addresses to Published.toml
|
|
7
|
+
// (not Move.lock). Because the deployer bypasses the CLI, we generate it when a
|
|
8
|
+
// PublishResult is available so the downloaded package is immediately usable as a
|
|
9
|
+
// dependency reference without requiring the user to reconstruct the file manually.
|
|
10
|
+
|
|
11
|
+
import { zipSync, strToU8 } from 'fflate'
|
|
12
|
+
import templateFiles from '../template-src/files.json'
|
|
13
|
+
import type { PublishResult, TokenConfig } from './types.js'
|
|
14
|
+
import { SAFE_TEXT, deriveStructName, validateIdentifier, hasAllowedIconScheme } from './validation.js'
|
|
15
|
+
|
|
16
|
+
const files = templateFiles as Record<string, string>
|
|
17
|
+
|
|
18
|
+
/** License identifiers may only contain SPDX-safe characters. */
|
|
19
|
+
const LICENSE_ID = /^[A-Za-z0-9.\-+]*$/
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Defence in depth: re-assert the invariants the form validator enforces before
|
|
23
|
+
* substituting any user value into Move source or the publish shell script. A
|
|
24
|
+
* value that fails here (e.g. a newline or quote that could break out of a Move
|
|
25
|
+
* `b"..."` literal) throws rather than producing a malformed/injectable package,
|
|
26
|
+
* so the generator is safe even if called with an unvalidated config.
|
|
27
|
+
*/
|
|
28
|
+
function assertSafeConfig(cfg: TokenConfig): void {
|
|
29
|
+
const idFields: Array<[string, string]> = [
|
|
30
|
+
['package name', cfg.packageName],
|
|
31
|
+
['module name', cfg.moduleName],
|
|
32
|
+
]
|
|
33
|
+
for (const [label, value] of idFields) {
|
|
34
|
+
const err = validateIdentifier(value)
|
|
35
|
+
if (err) throw new Error(`Invalid ${label}: ${err}`)
|
|
36
|
+
}
|
|
37
|
+
if (cfg.structName !== deriveStructName(cfg.moduleName)) {
|
|
38
|
+
throw new Error('Invalid struct name: must be the module name uppercased.')
|
|
39
|
+
}
|
|
40
|
+
const textFields: Array<[string, string]> = [
|
|
41
|
+
['symbol', cfg.symbol],
|
|
42
|
+
['name', cfg.name],
|
|
43
|
+
['description', cfg.description],
|
|
44
|
+
['icon URL', cfg.iconUrl],
|
|
45
|
+
]
|
|
46
|
+
for (const [label, value] of textFields) {
|
|
47
|
+
if (!SAFE_TEXT.test(value)) {
|
|
48
|
+
throw new Error(`Invalid ${label}: contains quotes, backslashes or control characters.`)
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
// Re-assert the icon-URL scheme allowlist here too (layered defence, independent of the
|
|
52
|
+
// form): a non-empty icon URL must use https:// or ipfs:// before it is baked into metadata.
|
|
53
|
+
if (cfg.iconUrl && !hasAllowedIconScheme(cfg.iconUrl)) {
|
|
54
|
+
throw new Error('Invalid icon URL: only https:// or ipfs:// URLs are allowed.')
|
|
55
|
+
}
|
|
56
|
+
if (!Number.isInteger(cfg.decimals) || cfg.decimals < 0 || cfg.decimals > 18) {
|
|
57
|
+
throw new Error('Invalid decimals: expected a whole number between 0 and 18.')
|
|
58
|
+
}
|
|
59
|
+
if (!LICENSE_ID.test(cfg.license)) {
|
|
60
|
+
throw new Error('Invalid license identifier.')
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Escape a string for safe use as a literal RegExp. */
|
|
65
|
+
function esc(s: string): string {
|
|
66
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Replace every occurrence of a plain-string needle. */
|
|
70
|
+
function replaceAll(input: string, needle: string, value: string): string {
|
|
71
|
+
return input.replace(new RegExp(esc(needle), 'g'), () => value)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Apply the shared X…X documentation placeholders. */
|
|
75
|
+
function applyDocPlaceholders(input: string, cfg: TokenConfig): string {
|
|
76
|
+
const map: Record<string, string> = {
|
|
77
|
+
XPACKAGENAMEX: cfg.packageName,
|
|
78
|
+
XMODULENAMEX: cfg.moduleName,
|
|
79
|
+
XSTRUCTNAMEX: cfg.structName,
|
|
80
|
+
XSYMBOLX: cfg.symbol,
|
|
81
|
+
XNAMEX: cfg.name,
|
|
82
|
+
XDESCRIPTIONX: cfg.description,
|
|
83
|
+
XDECIMALSX: String(cfg.decimals),
|
|
84
|
+
XPROJECTNAMEX: cfg.projectName,
|
|
85
|
+
XPACKAGEDESCRIPTIONX: cfg.packageDescription,
|
|
86
|
+
}
|
|
87
|
+
let out = input
|
|
88
|
+
for (const [k, v] of Object.entries(map)) out = replaceAll(out, k, v)
|
|
89
|
+
return out
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Chain IDs for the two production networks (hex short form).
|
|
93
|
+
const CHAIN_IDS: Record<string, string> = {
|
|
94
|
+
testnet: '4c78adac',
|
|
95
|
+
mainnet: '35834a8a',
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Sui CLI version that compiled the shipped template bytecode. Keep in sync
|
|
99
|
+
// with TEMPLATE_MODULE_B64 in src/move-template/template.ts; updated by regen-template.sh.
|
|
100
|
+
const TEMPLATE_TOOLCHAIN_VERSION = '1.76.1'
|
|
101
|
+
|
|
102
|
+
const isProprietary = (license: string) =>
|
|
103
|
+
['none', 'unlicensed', 'noassertion', ''].includes(license.trim().toLowerCase())
|
|
104
|
+
|
|
105
|
+
function spdxId(license: string): string {
|
|
106
|
+
return isProprietary(license) ? 'UNLICENSED' : license.trim()
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Move.toml: package name + license field. */
|
|
110
|
+
function renderMoveToml(cfg: TokenConfig): string {
|
|
111
|
+
let out = replaceAll(files['Move.toml'], 'sui_token_template', cfg.packageName)
|
|
112
|
+
out = out.replace(/^license = .*$/m, `license = "${spdxId(cfg.license)}"`)
|
|
113
|
+
return out
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** The Move source: identifiers, named constants, and the license header. */
|
|
117
|
+
function renderSource(cfg: TokenConfig): string {
|
|
118
|
+
let out = files['source.move']
|
|
119
|
+
|
|
120
|
+
// license header (lines 1-2)
|
|
121
|
+
const lines = out.split('\n')
|
|
122
|
+
if (isProprietary(cfg.license)) {
|
|
123
|
+
lines[0] = '// SPDX-License-Identifier: UNLICENSED'
|
|
124
|
+
lines[1] = '// All rights reserved. Proprietary and not licensed for redistribution.'
|
|
125
|
+
} else if (spdxId(cfg.license) !== 'CC0-1.0') {
|
|
126
|
+
lines[0] = `// SPDX-License-Identifier: ${spdxId(cfg.license)}`
|
|
127
|
+
lines[1] = `// Licensed under the ${spdxId(cfg.license)} license; see the LICENSE file.`
|
|
128
|
+
}
|
|
129
|
+
out = lines.join('\n')
|
|
130
|
+
|
|
131
|
+
out = replaceAll(
|
|
132
|
+
out,
|
|
133
|
+
'module sui_token_template::sui_token_template;',
|
|
134
|
+
`module ${cfg.packageName}::${cfg.moduleName};`,
|
|
135
|
+
)
|
|
136
|
+
out = replaceAll(out, 'SUI_TOKEN_TEMPLATE', cfg.structName)
|
|
137
|
+
out = replaceAll(out, 'XPACKAGEDESCRIPTIONX', cfg.packageDescription)
|
|
138
|
+
out = replaceAll(out, 'XMODULENAMEX', cfg.moduleName)
|
|
139
|
+
|
|
140
|
+
out = replaceAll(out, 'const DECIMALS: u8 = 9;', `const DECIMALS: u8 = ${cfg.decimals};`)
|
|
141
|
+
out = replaceAll(out, 'b"TEMPLATE_SYMBOL"', `b"${cfg.symbol}"`)
|
|
142
|
+
out = replaceAll(out, 'b"TEMPLATE_NAME"', `b"${cfg.name}"`)
|
|
143
|
+
out = replaceAll(out, 'b"TEMPLATE_DESCRIPTION"', `b"${cfg.description}"`)
|
|
144
|
+
out = replaceAll(out, 'b"TEMPLATE_ICON_URL"', `b"${cfg.iconUrl}"`)
|
|
145
|
+
return out
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function renderPublishScript(cfg: TokenConfig): string {
|
|
149
|
+
let out = replaceAll(files['scripts/publish.sh'], 'SUI_TOKEN_TEMPLATE', cfg.structName)
|
|
150
|
+
out = replaceAll(out, 'XMODULENAMEX', cfg.moduleName)
|
|
151
|
+
out = replaceAll(out, 'XPACKAGENAMEX', cfg.packageName)
|
|
152
|
+
return out
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function renderReadme(cfg: TokenConfig): string {
|
|
156
|
+
let out = applyDocPlaceholders(files['README.md'], cfg)
|
|
157
|
+
const licenseName = cfg.licenseName || spdxId(cfg.license)
|
|
158
|
+
if (isProprietary(cfg.license)) {
|
|
159
|
+
out = out.replace(
|
|
160
|
+
/^CC0 1\.0 Universal.*$/m,
|
|
161
|
+
'All rights reserved. This package is proprietary and not licensed for redistribution.',
|
|
162
|
+
)
|
|
163
|
+
} else if (spdxId(cfg.license) !== 'CC0-1.0') {
|
|
164
|
+
out = out.replace(/^CC0 1\.0 Universal.*$/m, `${licenseName} — see the LICENSE file.`)
|
|
165
|
+
}
|
|
166
|
+
return out
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Replace a single table row in a markdown section in-place.
|
|
171
|
+
* Matches: `| <fieldName> | \`FILL_IN_AFTER_DEPLOY\` |`
|
|
172
|
+
*/
|
|
173
|
+
function fillRow(section: string, fieldName: string, value: string): string {
|
|
174
|
+
return section.replace(
|
|
175
|
+
`| ${fieldName} | \`FILL_IN_AFTER_DEPLOY\` |`,
|
|
176
|
+
`| ${fieldName} | \`${value}\` |`,
|
|
177
|
+
)
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Fill the deployment table rows for the specified network section only.
|
|
182
|
+
* Operates on the substring between the section header and the next `## ` heading.
|
|
183
|
+
*/
|
|
184
|
+
function fillNetworkSection(doc: string, network: string, result: PublishResult): string {
|
|
185
|
+
const header = `## ${network.charAt(0).toUpperCase() + network.slice(1)}\n`
|
|
186
|
+
const start = doc.indexOf(header)
|
|
187
|
+
if (start === -1) return doc
|
|
188
|
+
const afterHeader = start + header.length
|
|
189
|
+
const nextSection = doc.indexOf('\n## ', afterHeader)
|
|
190
|
+
const end = nextSection === -1 ? doc.length : nextSection
|
|
191
|
+
|
|
192
|
+
let section = doc.slice(start, end)
|
|
193
|
+
|
|
194
|
+
section = fillRow(section, 'Package ID', result.packageId)
|
|
195
|
+
if (result.treasuryCapId) section = fillRow(section, 'TreasuryCap ID', result.treasuryCapId)
|
|
196
|
+
if (result.metadataCapId) section = fillRow(section, 'MetadataCap ID', result.metadataCapId)
|
|
197
|
+
if (result.currencyId) section = fillRow(section, 'Currency object ID', result.currencyId)
|
|
198
|
+
|
|
199
|
+
if (result.upgradeCapId) {
|
|
200
|
+
section = fillRow(section, 'UpgradeCap ID (burned)', result.upgradeCapId)
|
|
201
|
+
section = section.replace(
|
|
202
|
+
'| Immutability confirmed | Yes (Y/N) |',
|
|
203
|
+
'| Immutability confirmed | No (pending) |',
|
|
204
|
+
)
|
|
205
|
+
} else {
|
|
206
|
+
section = fillRow(section, 'UpgradeCap ID (burned)', 'burned')
|
|
207
|
+
section = section.replace(
|
|
208
|
+
'| Immutability confirmed | Yes (Y/N) |',
|
|
209
|
+
'| Immutability confirmed | Yes |',
|
|
210
|
+
)
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const deployed = new Date().toISOString().replace('T', ' ').split('.')[0] + ' UTC'
|
|
214
|
+
section = fillRow(section, 'Deployed', deployed)
|
|
215
|
+
|
|
216
|
+
return doc.slice(0, start) + section + doc.slice(end)
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** Optionally fill deployments.md with the just-published IDs. */
|
|
220
|
+
function renderDeployments(cfg: TokenConfig, result?: PublishResult): string {
|
|
221
|
+
let out = applyDocPlaceholders(files['deployments.md'], cfg)
|
|
222
|
+
if (!result) return out
|
|
223
|
+
|
|
224
|
+
// Fill the network-specific table section with known artefact IDs
|
|
225
|
+
out = fillNetworkSection(out, result.network, result)
|
|
226
|
+
|
|
227
|
+
// Fill the <PACKAGE_ID> placeholder in the downstream Move.toml reference block
|
|
228
|
+
out = out.replace(/<PACKAGE_ID>/g, result.packageId)
|
|
229
|
+
|
|
230
|
+
// Append extra data not represented in the table (coin type, digest)
|
|
231
|
+
const note = [
|
|
232
|
+
'',
|
|
233
|
+
`<!-- Auto-filled by the Token Deployer on ${new Date().toISOString()} (${result.network}) -->`,
|
|
234
|
+
`<!-- Coin type: ${result.coinType} -->`,
|
|
235
|
+
`<!-- Publish digest: ${result.digest} -->`,
|
|
236
|
+
'',
|
|
237
|
+
].join('\n')
|
|
238
|
+
return `${out.trimEnd()}\n${note}`
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Generate a Published.toml for the deployed package.
|
|
243
|
+
* Since v1.63, the Sui CLI writes Published.toml (not Move.lock) to record deployed
|
|
244
|
+
* addresses per environment. The deployer bypasses the CLI, so we produce this file
|
|
245
|
+
* so the downloaded package is immediately usable as a Move dependency reference.
|
|
246
|
+
* Commit Published.toml to source control; add Pub.*.toml (ephemeral) to .gitignore.
|
|
247
|
+
*/
|
|
248
|
+
function renderPublishedToml(result: PublishResult): string {
|
|
249
|
+
const env = result.network === 'mainnet' ? 'mainnet' : 'testnet'
|
|
250
|
+
const chainId = CHAIN_IDS[env] ?? ''
|
|
251
|
+
const lines = [
|
|
252
|
+
`[published.${env}]`,
|
|
253
|
+
`chain-id = "${chainId}"`,
|
|
254
|
+
`original-id = "${result.packageId}"`,
|
|
255
|
+
`published-at = "${result.packageId}"`,
|
|
256
|
+
`version = 1`,
|
|
257
|
+
`toolchain-version = "${TEMPLATE_TOOLCHAIN_VERSION}"`,
|
|
258
|
+
`build-config = { flavor = "sui", edition = "2024" }`,
|
|
259
|
+
]
|
|
260
|
+
if (result.upgradeCapId) {
|
|
261
|
+
lines.push(`upgrade-capability = "${result.upgradeCapId}"`)
|
|
262
|
+
} else {
|
|
263
|
+
lines.push(
|
|
264
|
+
`# upgrade-capability burned via 0x2::package::make_immutable — package is immutable`,
|
|
265
|
+
)
|
|
266
|
+
}
|
|
267
|
+
return lines.join('\n') + '\n'
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
export interface GeneratePackageOptions {
|
|
271
|
+
config: TokenConfig
|
|
272
|
+
/** Full license text; when omitted for a real license, no LICENSE file is written. */
|
|
273
|
+
licenseText?: string
|
|
274
|
+
/** Publish result, to pre-fill deployments.md. */
|
|
275
|
+
result?: PublishResult
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** Build the in-memory file map for the generated package. */
|
|
279
|
+
export function buildPackageFiles(opts: GeneratePackageOptions): Record<string, string> {
|
|
280
|
+
const { config, licenseText } = opts
|
|
281
|
+
assertSafeConfig(config)
|
|
282
|
+
const out: Record<string, string> = {
|
|
283
|
+
'Move.toml': renderMoveToml(config),
|
|
284
|
+
[`sources/${config.moduleName}.move`]: renderSource(config),
|
|
285
|
+
'scripts/publish.sh': renderPublishScript(config),
|
|
286
|
+
'.gitignore': files['.gitignore'],
|
|
287
|
+
'README.md': renderReadme(config),
|
|
288
|
+
'deployments.md': renderDeployments(config, opts.result),
|
|
289
|
+
'CLAUDE.md': applyDocPlaceholders(files['CLAUDE.md'], config),
|
|
290
|
+
'AGENTS.md': applyDocPlaceholders(files['AGENTS.md'], config),
|
|
291
|
+
}
|
|
292
|
+
if (opts.result) {
|
|
293
|
+
// Published.toml records the deployed address per environment (Sui v1.63+ convention).
|
|
294
|
+
// Commit this file to source control; add Pub.*.toml (ephemeral) to .gitignore.
|
|
295
|
+
out['Published.toml'] = renderPublishedToml(opts.result)
|
|
296
|
+
}
|
|
297
|
+
if (!isProprietary(config.license) && licenseText) {
|
|
298
|
+
out['LICENSE'] = licenseText
|
|
299
|
+
}
|
|
300
|
+
return out
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/** Zip the generated package. Returns bytes suitable for a download Blob. */
|
|
304
|
+
export function generatePackageZip(opts: GeneratePackageOptions): Uint8Array {
|
|
305
|
+
const filesMap = buildPackageFiles(opts)
|
|
306
|
+
const zipInput: Record<string, Uint8Array> = {}
|
|
307
|
+
const root = opts.config.packageName
|
|
308
|
+
for (const [path, content] of Object.entries(filesMap)) {
|
|
309
|
+
zipInput[`${root}/${path}`] = strToU8(content)
|
|
310
|
+
}
|
|
311
|
+
return zipSync(zipInput, { level: 6 })
|
|
312
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
// Optional, ZERO-BACKEND GitHub repo creation. Uses the user's OWN token (a
|
|
2
|
+
// fine-grained PAT with "Contents: read and write" + "Administration: read and
|
|
3
|
+
// write", or a classic `repo`-scoped token), calling the GitHub REST API
|
|
4
|
+
// directly from the browser (CORS-enabled). The repo is created in the user's
|
|
5
|
+
// account; this app's backend is never involved.
|
|
6
|
+
|
|
7
|
+
const DEFAULT_API_BASE = 'https://api.github.com'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* UTF-8-safe base64 encoding that works in both the browser and Node — used to
|
|
11
|
+
* encode file contents for the GitHub Contents API.
|
|
12
|
+
*
|
|
13
|
+
* @param input Text to encode.
|
|
14
|
+
* @returns The base64 representation of the UTF-8 bytes.
|
|
15
|
+
*/
|
|
16
|
+
export function toBase64Utf8(input: string): string {
|
|
17
|
+
const bytes = new TextEncoder().encode(input)
|
|
18
|
+
let binary = ''
|
|
19
|
+
for (const b of bytes) binary += String.fromCharCode(b)
|
|
20
|
+
// btoa is available in browsers and modern Node globals.
|
|
21
|
+
return btoa(binary)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Arguments for {@link createRepoAndPush}. */
|
|
25
|
+
export interface CreateRepoAndPushArgs {
|
|
26
|
+
/** The user's own GitHub token; sent only to the GitHub API over HTTPS. */
|
|
27
|
+
token: string
|
|
28
|
+
/** Name of the repository to create in the user's account. */
|
|
29
|
+
repoName: string
|
|
30
|
+
/** Optional repository description. */
|
|
31
|
+
description?: string
|
|
32
|
+
/** Create the repository as private (default: public). */
|
|
33
|
+
isPrivate?: boolean
|
|
34
|
+
/** Map of repo-relative path -> file contents (text). */
|
|
35
|
+
files: Record<string, string>
|
|
36
|
+
/** Commit message for each file push (default: `Add <path>`). */
|
|
37
|
+
commitMessage?: string
|
|
38
|
+
/** Override the GitHub API base (must be HTTPS); defaults to api.github.com. */
|
|
39
|
+
apiBase?: string
|
|
40
|
+
/** Injectable fetch implementation (for testing). */
|
|
41
|
+
fetcher?: typeof fetch
|
|
42
|
+
/** Progress callback invoked after each file is pushed. */
|
|
43
|
+
onProgress?: (done: number, total: number) => void
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** The created repository's identity, returned by {@link createRepoAndPush}. */
|
|
47
|
+
export interface GithubRepoResult {
|
|
48
|
+
/** The owning account login. */
|
|
49
|
+
owner: string
|
|
50
|
+
/** The repository name. */
|
|
51
|
+
repo: string
|
|
52
|
+
/** The repository's `html_url`. */
|
|
53
|
+
htmlUrl: string
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function ghHeaders(token: string): HeadersInit {
|
|
57
|
+
return {
|
|
58
|
+
Authorization: `Bearer ${token}`,
|
|
59
|
+
Accept: 'application/vnd.github+json',
|
|
60
|
+
'Content-Type': 'application/json',
|
|
61
|
+
'X-GitHub-Api-Version': '2022-11-28',
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Require an https API base so the PAT is never sent over cleartext. */
|
|
66
|
+
function assertHttps(apiBase: string): void {
|
|
67
|
+
let url: URL
|
|
68
|
+
try {
|
|
69
|
+
url = new URL(apiBase)
|
|
70
|
+
} catch {
|
|
71
|
+
throw new Error('GitHub: invalid API base URL.')
|
|
72
|
+
}
|
|
73
|
+
if (url.protocol !== 'https:') {
|
|
74
|
+
throw new Error('GitHub: refusing to send your token over a non-HTTPS connection.')
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Encode a repo file path segment-by-segment, preserving the `/` separators. */
|
|
79
|
+
function encodePath(path: string): string {
|
|
80
|
+
return path
|
|
81
|
+
.split('/')
|
|
82
|
+
.filter((s) => s !== '')
|
|
83
|
+
.map(encodeURIComponent)
|
|
84
|
+
.join('/')
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async function ghError(res: Response, fallback: string): Promise<never> {
|
|
88
|
+
let message = fallback
|
|
89
|
+
try {
|
|
90
|
+
const body = (await res.json()) as { message?: string }
|
|
91
|
+
if (body?.message) message = body.message
|
|
92
|
+
} catch {
|
|
93
|
+
/* ignore */
|
|
94
|
+
}
|
|
95
|
+
throw new Error(`GitHub: ${message} (${res.status})`)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Create a repo in the user's account and push each file (one commit per file
|
|
100
|
+
* via the Contents API; the first commit initialises the default branch). Returns
|
|
101
|
+
* the repo's html_url.
|
|
102
|
+
*/
|
|
103
|
+
export async function createRepoAndPush(args: CreateRepoAndPushArgs): Promise<GithubRepoResult> {
|
|
104
|
+
const fetcher = args.fetcher ?? fetch
|
|
105
|
+
const apiBase = args.apiBase ?? DEFAULT_API_BASE
|
|
106
|
+
assertHttps(apiBase)
|
|
107
|
+
|
|
108
|
+
const createRes = await fetcher(`${apiBase}/user/repos`, {
|
|
109
|
+
method: 'POST',
|
|
110
|
+
headers: ghHeaders(args.token),
|
|
111
|
+
body: JSON.stringify({
|
|
112
|
+
name: args.repoName,
|
|
113
|
+
description: args.description ?? '',
|
|
114
|
+
private: args.isPrivate ?? false,
|
|
115
|
+
auto_init: false,
|
|
116
|
+
}),
|
|
117
|
+
})
|
|
118
|
+
if (!createRes.ok) await ghError(createRes, 'could not create the repository')
|
|
119
|
+
const repo = (await createRes.json()) as {
|
|
120
|
+
full_name: string
|
|
121
|
+
html_url: string
|
|
122
|
+
owner: { login: string }
|
|
123
|
+
name: string
|
|
124
|
+
}
|
|
125
|
+
const owner = repo.owner.login
|
|
126
|
+
|
|
127
|
+
const entries = Object.entries(args.files)
|
|
128
|
+
let done = 0
|
|
129
|
+
for (const [path, content] of entries) {
|
|
130
|
+
const putRes = await fetcher(
|
|
131
|
+
`${apiBase}/repos/${owner}/${encodeURIComponent(repo.name)}/contents/${encodePath(path)}`,
|
|
132
|
+
{
|
|
133
|
+
method: 'PUT',
|
|
134
|
+
headers: ghHeaders(args.token),
|
|
135
|
+
body: JSON.stringify({
|
|
136
|
+
message: args.commitMessage ?? `Add ${path}`,
|
|
137
|
+
content: toBase64Utf8(content),
|
|
138
|
+
}),
|
|
139
|
+
},
|
|
140
|
+
)
|
|
141
|
+
if (!putRes.ok) await ghError(putRes, `could not add ${path}`)
|
|
142
|
+
args.onProgress?.(++done, entries.length)
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return { owner, repo: repo.name, htmlUrl: repo.html_url }
|
|
146
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
// Dynamic license catalogue, sourced from the SPDX license-list-data archive at
|
|
2
|
+
// runtime (CORS-enabled, `access-control-allow-origin: *`). No registry is
|
|
3
|
+
// bundled or maintained here. License choice only affects the downloadable
|
|
4
|
+
// package (LICENSE file + Move.toml/README/source header), never the on-chain
|
|
5
|
+
// bytecode.
|
|
6
|
+
|
|
7
|
+
const SPDX_BASE = 'https://raw.githubusercontent.com/spdx/license-list-data/main'
|
|
8
|
+
const LIST_URL = `${SPDX_BASE}/json/licenses.json`
|
|
9
|
+
const textUrl = (id: string) => `${SPDX_BASE}/text/${encodeURIComponent(id)}.txt`
|
|
10
|
+
|
|
11
|
+
export interface SpdxLicense {
|
|
12
|
+
id: string
|
|
13
|
+
name: string
|
|
14
|
+
osiApproved: boolean
|
|
15
|
+
deprecated: boolean
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** The "no license" / proprietary choice; never fetched. */
|
|
19
|
+
export const NO_LICENSE: SpdxLicense = {
|
|
20
|
+
id: 'NONE',
|
|
21
|
+
name: 'None / proprietary (all rights reserved)',
|
|
22
|
+
osiApproved: false,
|
|
23
|
+
deprecated: false,
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Ids surfaced first in the picker. CC0 is the project default. */
|
|
27
|
+
export const POPULAR_LICENSE_IDS = [
|
|
28
|
+
'CC0-1.0',
|
|
29
|
+
'MIT',
|
|
30
|
+
'Apache-2.0',
|
|
31
|
+
'BSD-3-Clause',
|
|
32
|
+
'BSD-2-Clause',
|
|
33
|
+
'GPL-3.0-only',
|
|
34
|
+
'GPL-2.0-only',
|
|
35
|
+
'LGPL-3.0-only',
|
|
36
|
+
'AGPL-3.0-only',
|
|
37
|
+
'MPL-2.0',
|
|
38
|
+
'ISC',
|
|
39
|
+
'Unlicense',
|
|
40
|
+
] as const
|
|
41
|
+
|
|
42
|
+
export const DEFAULT_LICENSE_ID = 'CC0-1.0'
|
|
43
|
+
|
|
44
|
+
type Fetcher = typeof fetch
|
|
45
|
+
|
|
46
|
+
/** SPDX ids are limited to these characters; anything else is rejected. */
|
|
47
|
+
const LICENSE_ID_RE = /^[A-Za-z0-9.\-+]+$/
|
|
48
|
+
|
|
49
|
+
interface RawLicense {
|
|
50
|
+
licenseId: string
|
|
51
|
+
name: string
|
|
52
|
+
isOsiApproved?: boolean
|
|
53
|
+
isDeprecatedLicenseId?: boolean
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
let listCache: SpdxLicense[] | null = null
|
|
57
|
+
const textCache = new Map<string, string>()
|
|
58
|
+
|
|
59
|
+
export interface LicenseCatalogue {
|
|
60
|
+
/** Popular licenses first (in POPULAR order), each also present in `all`. */
|
|
61
|
+
popular: SpdxLicense[]
|
|
62
|
+
/** All non-deprecated licenses, alphabetical by id, prefixed with NO_LICENSE. */
|
|
63
|
+
all: SpdxLicense[]
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Fetch and cache the SPDX license list. */
|
|
67
|
+
export async function fetchLicenseList(fetcher: Fetcher = fetch): Promise<LicenseCatalogue> {
|
|
68
|
+
if (!listCache) {
|
|
69
|
+
const res = await fetcher(LIST_URL)
|
|
70
|
+
if (!res.ok) throw new Error(`failed to load SPDX license list (${res.status})`)
|
|
71
|
+
const json = (await res.json()) as { licenses?: RawLicense[] }
|
|
72
|
+
if (!json || !Array.isArray(json.licenses)) {
|
|
73
|
+
throw new Error('unexpected SPDX license list format')
|
|
74
|
+
}
|
|
75
|
+
listCache = json.licenses
|
|
76
|
+
.filter(
|
|
77
|
+
(l) =>
|
|
78
|
+
l &&
|
|
79
|
+
typeof l.licenseId === 'string' &&
|
|
80
|
+
LICENSE_ID_RE.test(l.licenseId) &&
|
|
81
|
+
typeof l.name === 'string' &&
|
|
82
|
+
!l.isDeprecatedLicenseId,
|
|
83
|
+
)
|
|
84
|
+
.map((l) => ({
|
|
85
|
+
id: l.licenseId,
|
|
86
|
+
name: l.name,
|
|
87
|
+
osiApproved: Boolean(l.isOsiApproved),
|
|
88
|
+
deprecated: Boolean(l.isDeprecatedLicenseId),
|
|
89
|
+
}))
|
|
90
|
+
.sort((a, b) => a.id.localeCompare(b.id))
|
|
91
|
+
}
|
|
92
|
+
const byId = new Map(listCache.map((l) => [l.id, l]))
|
|
93
|
+
const popular = POPULAR_LICENSE_IDS.map((id) => byId.get(id)).filter((l): l is SpdxLicense =>
|
|
94
|
+
Boolean(l),
|
|
95
|
+
)
|
|
96
|
+
return { popular, all: [NO_LICENSE, ...listCache] }
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Fetch and cache the full license text for an id. Returns null for the
|
|
101
|
+
* proprietary/no-license choice (no LICENSE file should be written).
|
|
102
|
+
*/
|
|
103
|
+
export async function fetchLicenseText(
|
|
104
|
+
id: string,
|
|
105
|
+
fetcher: Fetcher = fetch,
|
|
106
|
+
): Promise<string | null> {
|
|
107
|
+
const key = id.trim()
|
|
108
|
+
if (!key || ['none', 'unlicensed', 'noassertion'].includes(key.toLowerCase())) return null
|
|
109
|
+
if (!LICENSE_ID_RE.test(key)) throw new Error(`invalid license id "${key}"`)
|
|
110
|
+
if (textCache.has(key)) return textCache.get(key) as string
|
|
111
|
+
const res = await fetcher(textUrl(key))
|
|
112
|
+
if (!res.ok) throw new Error(`failed to load license text for "${key}" (${res.status})`)
|
|
113
|
+
const text = await res.text()
|
|
114
|
+
textCache.set(key, text)
|
|
115
|
+
return text
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Test seam: reset caches. */
|
|
119
|
+
export function __resetLicenseCaches(): void {
|
|
120
|
+
listCache = null
|
|
121
|
+
textCache.clear()
|
|
122
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// Cached SuiClient per network for read-only queries (getOwnedObjects, etc.).
|
|
2
|
+
// Replaces getSuiClient from the deleted useSuiClient.ts composable.
|
|
3
|
+
import { SuiJsonRpcClient } from '@mysten/sui/jsonRpc'
|
|
4
|
+
import type { Network } from './types.js'
|
|
5
|
+
import { RPC_URLS } from '../config.js'
|
|
6
|
+
|
|
7
|
+
const clients = new Map<Network, SuiJsonRpcClient>()
|
|
8
|
+
|
|
9
|
+
/** Return a memoised {@link SuiJsonRpcClient} for the given network. */
|
|
10
|
+
export function getReadClient(network: Network): SuiJsonRpcClient {
|
|
11
|
+
let c = clients.get(network)
|
|
12
|
+
if (!c) {
|
|
13
|
+
c = new SuiJsonRpcClient({ url: RPC_URLS[network], network })
|
|
14
|
+
clients.set(network, c)
|
|
15
|
+
}
|
|
16
|
+
return c
|
|
17
|
+
}
|