@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.
Files changed (103) hide show
  1. package/.claude/move-language-reference/SKILL.md +445 -0
  2. package/.claude/skills/move-language-reference/SKILL.md +445 -0
  3. package/.claude/skills/sui-move-bootcamp/SKILL.md +128 -0
  4. package/.claude/sui-move-bootcamp/SKILL.md +128 -0
  5. package/.editorconfig +8 -0
  6. package/.env.example +75 -0
  7. package/.env.production +7 -0
  8. package/.gitattributes +1 -0
  9. package/.github/workflows/ci.yml +34 -0
  10. package/.github/workflows/token-deployer-e2e.yml +64 -0
  11. package/.oxfmtrc.json +5 -0
  12. package/.oxlintrc.json +10 -0
  13. package/.vscode/extensions.json +9 -0
  14. package/AGENTS.md +39 -0
  15. package/CLAUDE.md +159 -0
  16. package/LICENSE +121 -0
  17. package/README.md +234 -0
  18. package/env.d.ts +13 -0
  19. package/index.html +13 -0
  20. package/package.json +67 -0
  21. package/playwright.config.ts +30 -0
  22. package/public/CNAME +1 -0
  23. package/public/_headers +12 -0
  24. package/public/_redirects +1 -0
  25. package/public/favicon.ico +0 -0
  26. package/public/favicon.svg +5 -0
  27. package/public/og-image.png +0 -0
  28. package/public/robots.txt +3 -0
  29. package/public/sitemap.xml +8 -0
  30. package/scripts/e2e-browser.mjs +15 -0
  31. package/scripts/e2e-deploy.mjs +222 -0
  32. package/scripts/e2e-localnet.mjs +65 -0
  33. package/scripts/e2e-walrus-browser.mjs +115 -0
  34. package/scripts/gen-og-image.mjs +31 -0
  35. package/scripts/publish-localnet.mjs +55 -0
  36. package/scripts/regen-template.sh +45 -0
  37. package/scripts/sync-template-src.sh +36 -0
  38. package/scripts/verify-bytecode.mjs +49 -0
  39. package/src/App.vue +41 -0
  40. package/src/__tests__/App.spec.js +11 -0
  41. package/src/components/AppNotice.vue +15 -0
  42. package/src/components/ColorModeControl.vue +81 -0
  43. package/src/components/ConfigForm.vue +192 -0
  44. package/src/components/DeployProgress.vue +46 -0
  45. package/src/components/FieldHint.vue +79 -0
  46. package/src/components/FormField.vue +28 -0
  47. package/src/components/FormSelect.vue +43 -0
  48. package/src/components/GithubPush.vue +100 -0
  49. package/src/components/IconPicker.vue +410 -0
  50. package/src/components/LicensePicker.vue +182 -0
  51. package/src/components/ResultPanel.vue +88 -0
  52. package/src/components/ReviewPanel.vue +69 -0
  53. package/src/components/TermsDialog.vue +81 -0
  54. package/src/components/TokenDeployerView.vue +115 -0
  55. package/src/components/WalletBar.vue +108 -0
  56. package/src/components/WalrusBlobBrowser.vue +188 -0
  57. package/src/components/WalrusIconUpload.vue +205 -0
  58. package/src/composables/useAccessGate.ts +122 -0
  59. package/src/composables/useColorMode.ts +76 -0
  60. package/src/composables/useWalrusRelay.ts +21 -0
  61. package/src/config.ts +185 -0
  62. package/src/index.ts +1 -0
  63. package/src/lib/accessGate.ts +141 -0
  64. package/src/lib/buildPublishTx.ts +192 -0
  65. package/src/lib/deploy.ts +168 -0
  66. package/src/lib/deployExecutor.ts +61 -0
  67. package/src/lib/errors.ts +21 -0
  68. package/src/lib/form.ts +75 -0
  69. package/src/lib/generatePackage.ts +312 -0
  70. package/src/lib/github.ts +146 -0
  71. package/src/lib/licenses.ts +122 -0
  72. package/src/lib/readClient.ts +17 -0
  73. package/src/lib/template.ts +131 -0
  74. package/src/lib/types.ts +69 -0
  75. package/src/lib/validation.ts +172 -0
  76. package/src/lib/walrus-constants.ts +15 -0
  77. package/src/lib/walrus.ts +144 -0
  78. package/src/main.ts +154 -0
  79. package/src/move-template/sui_token_template.mv +0 -0
  80. package/src/move-template/template.ts +31 -0
  81. package/src/styles.css +384 -0
  82. package/src/template-src/files.json +10 -0
  83. package/test-results/.last-run.json +4 -0
  84. package/tests/accessGate.test.ts +108 -0
  85. package/tests/buildPublishTx.test.ts +243 -0
  86. package/tests/deploy.test.ts +81 -0
  87. package/tests/errors.test.ts +32 -0
  88. package/tests/generatePackage.test.ts +182 -0
  89. package/tests/github.test.ts +61 -0
  90. package/tests/licenses.test.ts +78 -0
  91. package/tests/template.test.ts +93 -0
  92. package/tests/templateArtifact.test.ts +61 -0
  93. package/tests/templateParity.test.ts +55 -0
  94. package/tests/useAccessGate.test.ts +101 -0
  95. package/tests/useWallet.test.ts +74 -0
  96. package/tests/useWalrusRelayGating.test.ts +30 -0
  97. package/tests/validation.test.ts +117 -0
  98. package/tests/walrusUpload.test.ts +93 -0
  99. package/tests/walrus_url_patch.test.ts +26 -0
  100. package/tsconfig.json +19 -0
  101. package/typedoc.json +34 -0
  102. package/vite.config.ts +65 -0
  103. 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
+ }