@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,410 @@
1
+ <script setup lang="ts">
2
+ import { ref, computed, onMounted, watch } from 'vue'
3
+ import { useWallet } from '@meddleware/wallet-adapter'
4
+ import { buildDeployExecutor } from '../lib/deployExecutor.js'
5
+ import { getReadClient } from '../lib/readClient.js'
6
+ import { useWalrusRelay } from '../composables/useWalrusRelay.js'
7
+ import { useAccessGate } from '../composables/useAccessGate.js'
8
+ import { ICON_EPOCHS } from '../lib/walrus-constants.js'
9
+ import { ICON_MAX_BYTES, validateIconFile } from '../config.js'
10
+ import {
11
+ fetchRelayChallenge,
12
+ personalMessageForNonce,
13
+ buildAccessProofToken,
14
+ } from '../lib/accessGate.js'
15
+ import type { WalrusNetwork } from '../lib/walrus.js'
16
+ import type { Network } from '../lib/types.js'
17
+ import AppNotice from './AppNotice.vue'
18
+ import FieldHint from './FieldHint.vue'
19
+ import WalrusBlobBrowser from './WalrusBlobBrowser.vue'
20
+ import walrusWasmUrl from '@mysten/walrus-wasm/web/walrus_wasm_bg.wasm?url'
21
+
22
+ const props = defineProps<{
23
+ modelValue: string
24
+ network: Network
25
+ connected: boolean
26
+ error?: string
27
+ }>()
28
+
29
+ const emit = defineEmits<{
30
+ (e: 'update:modelValue', url: string): void
31
+ (e: 'blur'): void
32
+ }>()
33
+
34
+ const { account, signPersonalMessage } = useWallet()
35
+
36
+ const isWalrusNetwork = computed(() => props.network !== 'localnet')
37
+ const walrusNet: WalrusNetwork = props.network === 'mainnet' ? 'mainnet' : 'testnet'
38
+
39
+ // NFT-gated relay access (inert when no gate is configured for this network).
40
+ const accessGate = useAccessGate(walrusNet, { getClient: (n) => getReadClient(n) })
41
+
42
+ const {
43
+ selectedRelayHost,
44
+ availableRelays,
45
+ purchaseAccessAvailable,
46
+ estimatedCost,
47
+ fileSizeBytes,
48
+ checkOperatorRelayHealth,
49
+ } = useWalrusRelay(walrusNet, {
50
+ gateConfigured: accessGate.gateConfigured,
51
+ hasAccess: accessGate.hasAccess,
52
+ })
53
+
54
+ const purchasing = ref(false)
55
+
56
+ async function refreshAccess(): Promise<void> {
57
+ if (isWalrusNetwork.value && accessGate.gateConfigured && account.value) {
58
+ await accessGate.checkOwnership(account.value.address)
59
+ }
60
+ }
61
+
62
+ /** Buy relay access (mints the gate NFT to the wallet), then re-check ownership. */
63
+ async function purchaseAccess(): Promise<void> {
64
+ if (!account.value) {
65
+ uploadError.value = 'Connect your wallet first.'
66
+ return
67
+ }
68
+ purchasing.value = true
69
+ uploadError.value = null
70
+ try {
71
+ const executor = await buildDeployExecutor(props.network as Network)
72
+ await accessGate.purchase(executor, account.value.address)
73
+ } catch (e) {
74
+ uploadError.value = e instanceof Error ? e.message : String(e)
75
+ } finally {
76
+ purchasing.value = false
77
+ }
78
+ }
79
+
80
+ const tab = ref<'url' | 'upload' | 'browse'>('url')
81
+
82
+ // Upload state
83
+ const uploading = ref(false)
84
+ const status = ref('')
85
+ const uploadError = ref<string | null>(null)
86
+ const fileName = ref('')
87
+ let bytes: Uint8Array | null = null
88
+
89
+ const permanent = ref(true)
90
+ const maxSizeLabel = `${Math.round(ICON_MAX_BYTES / 1024)} KB`
91
+ const ICON_YEARS = Math.round(ICON_EPOCHS / 26)
92
+
93
+ const showFreeRelayTip = computed(() => {
94
+ const publicHost = availableRelays.value.find((r) => r.isPublic)?.host
95
+ return availableRelays.value.length > 1 && selectedRelayHost.value !== publicHost
96
+ })
97
+
98
+ onMounted(() => {
99
+ if (isWalrusNetwork.value) {
100
+ checkOperatorRelayHealth()
101
+ void refreshAccess()
102
+ }
103
+ })
104
+
105
+ // Re-check NFT ownership whenever the connected account changes.
106
+ watch(
107
+ () => account.value?.address,
108
+ () => {
109
+ void refreshAccess()
110
+ },
111
+ )
112
+
113
+ function onFile(e: Event): void {
114
+ uploadError.value = null
115
+ bytes = null
116
+ fileName.value = ''
117
+ const f = (e.target as HTMLInputElement).files?.[0]
118
+ if (!f) return
119
+ const invalid = validateIconFile(f)
120
+ if (invalid) { uploadError.value = invalid; return }
121
+ fileName.value = f.name
122
+ fileSizeBytes.value = f.size
123
+ const reader = new FileReader()
124
+ reader.onload = () => { bytes = new Uint8Array(reader.result as ArrayBuffer) }
125
+ reader.readAsArrayBuffer(f)
126
+ }
127
+
128
+ function onRelayChange(newHost: string): void {
129
+ selectedRelayHost.value = newHost
130
+ }
131
+
132
+ async function upload(): Promise<void> {
133
+ if (!bytes) { uploadError.value = 'Choose an image first.'; return }
134
+ if (!account.value) { uploadError.value = 'Connect your wallet first.'; return }
135
+ uploading.value = true
136
+ uploadError.value = null
137
+ try {
138
+ const { createWalrusClient, createBlobUploadFlow, walrusBlobUrl } = await import(
139
+ '../lib/walrus.js'
140
+ )
141
+ const executor = await buildDeployExecutor(props.network as Network)
142
+ const suiClient = getReadClient(props.network as Network)
143
+
144
+ // If the gated operator relay is selected, prove NFT access: (single-use only) consume
145
+ // one use on-chain bound to the challenge nonce, then sign the challenge. The token rides
146
+ // the relay Authorization header; the nft-gate gateway verifies it before proxying.
147
+ let uploadRelayAuthToken: string | undefined
148
+ const publicHost = availableRelays.value.find((r) => r.isPublic)?.host
149
+ const usingOperatorRelay = selectedRelayHost.value !== publicHost
150
+ if (accessGate.gateConfigured && usingOperatorRelay && account.value) {
151
+ const challenge = await fetchRelayChallenge(selectedRelayHost.value)
152
+ let consumeDigest: string | undefined
153
+ if (accessGate.usesRemaining.value != null && accessGate.nftId.value) {
154
+ status.value = 'Consuming one access use (approve in wallet)…'
155
+ const consumeTx = accessGate.buildConsume(accessGate.nftId.value, challenge.nonce)
156
+ const cres = await executor.signAndExecute(consumeTx)
157
+ await executor.waitForTransaction(cres.digest)
158
+ consumeDigest = cres.digest
159
+ }
160
+ status.value = 'Signing relay access proof…'
161
+ const { signature } = await signPersonalMessage(personalMessageForNonce(challenge.nonce))
162
+ uploadRelayAuthToken = buildAccessProofToken({
163
+ address: account.value.address,
164
+ nonce: challenge.nonce,
165
+ signature,
166
+ consumeDigest,
167
+ })
168
+ }
169
+
170
+ const client = createWalrusClient(walrusNet, {
171
+ wasmUrl: walrusWasmUrl,
172
+ uploadRelayHost: selectedRelayHost.value,
173
+ uploadRelayAuthToken,
174
+ })
175
+ const flow = createBlobUploadFlow(client, bytes)
176
+
177
+ status.value = 'Encoding…'
178
+ await flow.encode()
179
+
180
+ let feeNote = ''
181
+ try {
182
+ const tipMist = await client.walrus.calculateUploadRelayTip({ size: bytes.length })
183
+ const tipSui = Number(tipMist) / 1e9
184
+ feeNote = tipMist > 0n ? ` Relay fee ≈ ${tipSui.toFixed(4)} SUI + storage (WAL).` : ''
185
+ } catch {
186
+ // Non-fatal — the register step surfaces any real tip error.
187
+ }
188
+
189
+ status.value = `Registering blob (approve in wallet)…${feeNote}`
190
+ const regTx = flow.register({
191
+ owner: account.value.address,
192
+ epochs: ICON_EPOCHS,
193
+ deletable: !permanent.value,
194
+ })
195
+ regTx.setSenderIfNotSet(account.value.address)
196
+ await regTx.build({ client: suiClient })
197
+ const reg = await executor.signAndExecute(regTx)
198
+ await executor.waitForTransaction(reg.digest)
199
+
200
+ status.value = 'Uploading to the relay…'
201
+ await flow.upload({ digest: reg.digest })
202
+
203
+ status.value = 'Certifying (approve in wallet)…'
204
+ const certTx = flow.certify()
205
+ certTx.setSenderIfNotSet(account.value.address)
206
+ await certTx.build({ client: suiClient })
207
+ const cert = await executor.signAndExecute(certTx)
208
+ await executor.waitForTransaction(cert.digest)
209
+
210
+ const blob = await flow.getBlob()
211
+ emit('update:modelValue', walrusBlobUrl(walrusNet, blob.blobId))
212
+ tab.value = 'url'
213
+ status.value = 'Uploaded ✓ — URL filled in above.'
214
+ } catch (e) {
215
+ uploadError.value = e instanceof Error ? e.message : String(e)
216
+ status.value = ''
217
+ } finally {
218
+ uploading.value = false
219
+ }
220
+ }
221
+
222
+ function onBrowseSelect(url: string): void {
223
+ emit('update:modelValue', url)
224
+ tab.value = 'url'
225
+ }
226
+ </script>
227
+
228
+ <template>
229
+ <fieldset class="icon-picker">
230
+ <legend>Icon <span class="hint">(optional)</span></legend>
231
+
232
+ <div class="icon-tabs" role="group" aria-label="Icon source">
233
+ <label :class="{ active: tab === 'url' }">
234
+ <input type="radio" v-model="tab" value="url" />
235
+ URL
236
+ </label>
237
+ <label
238
+ :class="{ active: tab === 'upload', disabled: !isWalrusNetwork }"
239
+ :title="isWalrusNetwork ? undefined : 'Not available on localnet'"
240
+ >
241
+ <input type="radio" v-model="tab" value="upload" :disabled="!isWalrusNetwork" />
242
+ Upload to Walrus
243
+ </label>
244
+ <label
245
+ :class="{ active: tab === 'browse', disabled: !isWalrusNetwork }"
246
+ :title="isWalrusNetwork ? undefined : 'Not available on localnet'"
247
+ >
248
+ <input type="radio" v-model="tab" value="browse" :disabled="!isWalrusNetwork" />
249
+ My Walrus blobs
250
+ </label>
251
+ </div>
252
+
253
+ <!-- URL panel -->
254
+ <div v-show="tab === 'url'" class="icon-panel">
255
+ <input
256
+ id="icon"
257
+ type="url"
258
+ :value="modelValue"
259
+ placeholder="https://…"
260
+ :aria-invalid="error ? true : undefined"
261
+ :aria-describedby="error ? 'icon-error' : undefined"
262
+ @input="emit('update:modelValue', ($event.target as HTMLInputElement).value)"
263
+ @blur="emit('blur')"
264
+ />
265
+ <p v-if="error" id="icon-error" class="field-error">{{ error }}</p>
266
+ </div>
267
+
268
+ <!-- Upload panel -->
269
+ <div v-show="tab === 'upload' && isWalrusNetwork" class="icon-panel">
270
+ <div class="row">
271
+ <input
272
+ id="walrus-file"
273
+ type="file"
274
+ accept="image/*"
275
+ aria-label="Choose an image to upload to Walrus"
276
+ aria-describedby="walrus-help"
277
+ style="width: auto"
278
+ @change="onFile"
279
+ />
280
+ <button type="button" :disabled="uploading || !connected || !fileName" @click="upload">
281
+ <span v-if="uploading" class="spinner" aria-hidden="true"></span>
282
+ Upload to Walrus
283
+ </button>
284
+ </div>
285
+
286
+ <label class="permanent-toggle">
287
+ <input type="checkbox" v-model="permanent" />
288
+ Permanent blob <span class="hint">(cannot be deleted)</span>
289
+ <FieldHint field-id="blob-permanent">
290
+ A permanent blob cannot be deleted once registered, even within its storage period.
291
+ Deletable blobs can be removed early by the owner to reclaim unused storage fees (WAL).
292
+ Permanent is the safer default for a token icon that should remain available indefinitely.
293
+ </FieldHint>
294
+ </label>
295
+
296
+ <fieldset v-if="fileName && availableRelays.length > 1" style="margin: 0.5rem 0">
297
+ <legend>Upload relay</legend>
298
+ <div style="display: flex; flex-direction: column; gap: 0.5rem">
299
+ <label
300
+ v-for="option in availableRelays"
301
+ :key="option.host"
302
+ style="display: flex; align-items: center; gap: 0.5rem"
303
+ >
304
+ <input
305
+ type="radio"
306
+ :value="option.host"
307
+ :checked="selectedRelayHost === option.host"
308
+ @change="(e) => onRelayChange((e.target as HTMLInputElement).value)"
309
+ />
310
+ <span>{{ option.label }}</span>
311
+ </label>
312
+ </div>
313
+ </fieldset>
314
+
315
+ <AppNotice v-if="fileName && estimatedCost" style="margin: 0.5rem 0">
316
+ <strong>Estimated cost:</strong> {{ estimatedCost.label }}
317
+ </AppNotice>
318
+
319
+ <!-- NFT-gated relay: the operator relay is live but this wallet lacks the access NFT.
320
+ The public relay is still available; purchasing unlocks the operator relay. -->
321
+ <AppNotice v-if="fileName && purchaseAccessAvailable" style="margin: 0.5rem 0">
322
+ <p style="margin: 0 0 0.5rem">
323
+ The operator relay requires an access NFT. You can keep using the public relay, or
324
+ purchase access to support this app’s relay.
325
+ </p>
326
+ <button type="button" :disabled="purchasing || !connected" @click="purchaseAccess">
327
+ {{ purchasing ? 'Purchasing…' : 'Purchase relay access' }}
328
+ </button>
329
+ </AppNotice>
330
+
331
+ <p v-if="showFreeRelayTip" class="hint" style="margin: 0.5rem 0">
332
+ Tip: select the public relay above to avoid the relay fee.
333
+ </p>
334
+
335
+ <p id="walrus-help" class="hint" aria-live="polite">
336
+ Max {{ maxSizeLabel }} · PNG/JPEG/WebP/SVG. Requires <strong>WAL</strong> (storage) and
337
+ <strong>SUI</strong> (gas + relay fee) in your wallet; two approvals. Stored for
338
+ ~{{ ICON_YEARS }} years. <span v-if="status">{{ status }}</span>
339
+ </p>
340
+ <AppNotice v-if="uploadError" type="error">{{ uploadError }}</AppNotice>
341
+ </div>
342
+
343
+ <!-- Browse panel -->
344
+ <div v-show="tab === 'browse' && isWalrusNetwork" class="icon-panel">
345
+ <WalrusBlobBrowser :network="walrusNet" :connected="connected" @select="onBrowseSelect" />
346
+ </div>
347
+ </fieldset>
348
+ </template>
349
+
350
+ <style scoped>
351
+ .icon-tabs {
352
+ display: flex;
353
+ border: 1px solid var(--border);
354
+ border-radius: 6px;
355
+ overflow: hidden;
356
+ width: fit-content;
357
+ margin-bottom: 0.75rem;
358
+ }
359
+
360
+ .icon-tabs label {
361
+ display: flex;
362
+ align-items: center;
363
+ padding: 0.4rem 0.75rem;
364
+ cursor: pointer;
365
+ font-size: 0.875rem;
366
+ background: transparent;
367
+ border-right: 1px solid var(--border);
368
+ transition: background 0.15s, color 0.15s;
369
+ user-select: none;
370
+ }
371
+
372
+ .icon-tabs label:last-child {
373
+ border-right: none;
374
+ }
375
+
376
+ .icon-tabs label.active {
377
+ background: var(--accent);
378
+ color: var(--accent-contrast);
379
+ }
380
+
381
+ .icon-tabs label.disabled {
382
+ opacity: 0.5;
383
+ cursor: not-allowed;
384
+ pointer-events: none;
385
+ }
386
+
387
+ .icon-tabs input[type='radio'] {
388
+ position: absolute;
389
+ opacity: 0;
390
+ width: 0;
391
+ height: 0;
392
+ }
393
+
394
+ .icon-panel {
395
+ padding-top: 0.5rem;
396
+ }
397
+
398
+ .permanent-toggle {
399
+ display: flex;
400
+ align-items: center;
401
+ gap: 0.4rem;
402
+ margin-top: 0.5rem;
403
+ font-size: 0.875rem;
404
+ cursor: pointer;
405
+ }
406
+
407
+ .permanent-toggle input[type='checkbox'] {
408
+ margin: 0;
409
+ }
410
+ </style>
@@ -0,0 +1,182 @@
1
+ <script setup lang="ts">
2
+ import { computed, onMounted, ref } from 'vue'
3
+ import { fetchLicenseList, fetchLicenseText, NO_LICENSE } from '../lib/licenses.js'
4
+ import type { SpdxLicense } from '../lib/licenses.js'
5
+ import FormSelect from './FormSelect.vue'
6
+ import FieldHint from './FieldHint.vue'
7
+ import AppNotice from './AppNotice.vue'
8
+
9
+ const props = defineProps<{ modelValue: string }>()
10
+ const emit = defineEmits<{
11
+ (e: 'update:modelValue', id: string): void
12
+ (e: 'name', name: string): void
13
+ }>()
14
+
15
+ const popular = ref<SpdxLicense[]>([])
16
+ const all = ref<SpdxLicense[]>([])
17
+ const loading = ref(true)
18
+ const error = ref<string | null>(null)
19
+
20
+ const allExceptNone = computed(() => all.value.filter((x) => x.id !== NO_LICENSE.id))
21
+ const currentLicenseName = computed(
22
+ () => all.value.find((l) => l.id === props.modelValue)?.name ?? props.modelValue,
23
+ )
24
+
25
+ function emitSelection(id: string): void {
26
+ emit('update:modelValue', id)
27
+ const found =
28
+ all.value.find((l) => l.id === id) ?? (id === NO_LICENSE.id ? NO_LICENSE : undefined)
29
+ emit('name', found?.name ?? id)
30
+ }
31
+
32
+ const selectModel = computed({
33
+ get: () => props.modelValue,
34
+ set: (id: string) => emitSelection(id),
35
+ })
36
+
37
+ onMounted(async () => {
38
+ try {
39
+ const cat = await fetchLicenseList()
40
+ popular.value = cat.popular
41
+ all.value = cat.all
42
+ emitSelection(props.modelValue)
43
+ } catch (e) {
44
+ error.value = e instanceof Error ? e.message : String(e)
45
+ } finally {
46
+ loading.value = false
47
+ }
48
+ })
49
+
50
+ // License view modal
51
+ const licenseDialogEl = ref<HTMLDialogElement>()
52
+ const licenseText = ref<string | null>(null)
53
+ const licenseLoading = ref(false)
54
+ const licenseError = ref<string | null>(null)
55
+
56
+ async function openLicenseModal(): Promise<void> {
57
+ licenseError.value = null
58
+ if (props.modelValue !== NO_LICENSE.id) {
59
+ licenseLoading.value = true
60
+ try {
61
+ licenseText.value = await fetchLicenseText(props.modelValue)
62
+ } catch (e) {
63
+ licenseError.value = e instanceof Error ? e.message : String(e)
64
+ } finally {
65
+ licenseLoading.value = false
66
+ }
67
+ }
68
+ licenseDialogEl.value?.showModal()
69
+ }
70
+ </script>
71
+
72
+ <template>
73
+ <div>
74
+ <label for="license-select">
75
+ License
76
+ <FieldHint field-id="license-select">
77
+ Applies only to the downloadable Move source package — embedded in the LICENSE file and
78
+ source headers. Has <b>no effect on the on-chain token</b> or its transferability.
79
+ Choose <b>CC0-1.0</b> (default) for public domain. Choose <b>None</b> to retain all rights.
80
+ </FieldHint>
81
+ </label>
82
+ <div class="license-row">
83
+ <FormSelect
84
+ id="license-select"
85
+ v-model="selectModel"
86
+ :disabled="loading || Boolean(error)"
87
+ aria-describedby="license-load-status"
88
+ >
89
+ <option :value="NO_LICENSE.id" :title="NO_LICENSE.name">{{ NO_LICENSE.id }}</option>
90
+ <optgroup v-if="popular.length" label="Popular">
91
+ <option
92
+ v-for="l in popular"
93
+ :key="'p-' + l.id"
94
+ :value="l.id"
95
+ :title="`${l.id} — ${l.name}`"
96
+ >{{ l.id }}</option>
97
+ </optgroup>
98
+ <optgroup v-if="all.length" label="All licenses">
99
+ <option
100
+ v-for="l in allExceptNone"
101
+ :key="l.id"
102
+ :value="l.id"
103
+ :title="`${l.id} — ${l.name}`"
104
+ >{{ l.id }}</option>
105
+ </optgroup>
106
+ </FormSelect>
107
+ <button type="button" class="view-btn" :disabled="loading" @click="openLicenseModal">
108
+ View
109
+ </button>
110
+ </div>
111
+ <p id="license-load-status" class="hint" aria-live="polite">
112
+ <span v-if="loading"><span class="spinner" aria-hidden="true"></span> Loading licenses from SPDX…</span>
113
+ <span v-else-if="error" class="field-error">Couldn't load the license list: {{ error }}</span>
114
+ </p>
115
+ </div>
116
+
117
+ <dialog ref="licenseDialogEl" class="license-dialog" @click.self="licenseDialogEl?.close()">
118
+ <div class="dialog-header">
119
+ <h2>{{ currentLicenseName }}</h2>
120
+ <button
121
+ type="button"
122
+ aria-label="Close"
123
+ style="background: none; border: none; font-size: 1.5rem; padding: 0; cursor: pointer; color: var(--text)"
124
+ @click="licenseDialogEl?.close()"
125
+ >×</button>
126
+ </div>
127
+ <div v-if="licenseLoading" style="padding: 1rem 0">
128
+ <span class="spinner" aria-hidden="true"></span> Loading…
129
+ </div>
130
+ <AppNotice v-else-if="licenseError" type="error">{{ licenseError }}</AppNotice>
131
+ <template v-else-if="modelValue === NO_LICENSE.id">
132
+ <p>No LICENSE file will be generated. All rights are reserved by the author.</p>
133
+ </template>
134
+ <template v-else>
135
+ <p style="margin-top: 0">
136
+ <a
137
+ :href="`https://spdx.org/licenses/${modelValue}.html`"
138
+ target="_blank"
139
+ rel="noopener noreferrer"
140
+ >View on SPDX ↗</a>
141
+ </p>
142
+ <pre class="license-text">{{ licenseText }}</pre>
143
+ </template>
144
+ <div style="margin-top: 1rem; text-align: right">
145
+ <button type="button" @click="licenseDialogEl?.close()">Close</button>
146
+ </div>
147
+ </dialog>
148
+ </template>
149
+
150
+ <style scoped>
151
+ .license-row {
152
+ display: flex;
153
+ gap: 0.5rem;
154
+ align-items: center;
155
+ }
156
+
157
+ .license-row :deep(.select-wrapper) {
158
+ flex: 1;
159
+ }
160
+
161
+ .view-btn {
162
+ flex-shrink: 0;
163
+ white-space: nowrap;
164
+ }
165
+
166
+ .license-dialog {
167
+ width: min(680px, 92vw);
168
+ }
169
+
170
+ .license-text {
171
+ white-space: pre-wrap;
172
+ overflow-y: auto;
173
+ max-height: 55vh;
174
+ font-size: 0.8rem;
175
+ font-family: monospace;
176
+ background: var(--bg);
177
+ border: 1px solid var(--border);
178
+ border-radius: 6px;
179
+ padding: 0.75rem;
180
+ margin: 0;
181
+ }
182
+ </style>
@@ -0,0 +1,88 @@
1
+ <script setup lang="ts">
2
+ import { computed, ref } from 'vue'
3
+ import type { PublishResult, TokenConfig } from '../lib/types.js'
4
+ import { explorerObjectUrl } from '../config.js'
5
+ import { generatePackageZip } from '../lib/generatePackage.js'
6
+ import { fetchLicenseText } from '../lib/licenses.js'
7
+ import GithubPush from './GithubPush.vue'
8
+ import AppNotice from './AppNotice.vue'
9
+
10
+ const props = defineProps<{ result: PublishResult; config: TokenConfig }>()
11
+ defineEmits<{ (e: 'restart'): void }>()
12
+
13
+ const downloading = ref(false)
14
+ const downloadError = ref<string | null>(null)
15
+
16
+ const packageUrl = computed(() => explorerObjectUrl(props.result.network, props.result.packageId))
17
+
18
+ async function downloadPackage(): Promise<void> {
19
+ downloading.value = true
20
+ downloadError.value = null
21
+ try {
22
+ const licenseText = await fetchLicenseText(props.config.license)
23
+ const zip = generatePackageZip({
24
+ config: props.config,
25
+ licenseText: licenseText ?? undefined,
26
+ result: props.result,
27
+ })
28
+ const blob = new Blob([zip as BlobPart], { type: 'application/zip' })
29
+ const url = URL.createObjectURL(blob)
30
+ const a = document.createElement('a')
31
+ a.href = url
32
+ a.download = `${props.config.packageName}.zip`
33
+ document.body.appendChild(a)
34
+ a.click()
35
+ a.remove()
36
+ URL.revokeObjectURL(url)
37
+ } catch (e) {
38
+ downloadError.value = e instanceof Error ? e.message : String(e)
39
+ } finally {
40
+ downloading.value = false
41
+ }
42
+ }
43
+ </script>
44
+
45
+ <template>
46
+ <section class="card" aria-labelledby="result-h">
47
+ <h2 id="result-h">🎉 {{ config.symbol }} is live on {{ result.network }}</h2>
48
+
49
+ <dl>
50
+ <dt>Coin type</dt>
51
+ <dd class="mono">{{ result.coinType }}</dd>
52
+ <dt>Package</dt>
53
+ <dd class="mono">
54
+ <a :href="packageUrl" target="_blank" rel="noopener noreferrer">
55
+ {{ result.packageId }}
56
+ </a>
57
+ </dd>
58
+ <dt v-if="result.treasuryCapId">TreasuryCap</dt>
59
+ <dd v-if="result.treasuryCapId" class="mono">{{ result.treasuryCapId }}</dd>
60
+ <dt v-if="result.metadataCapId">MetadataCap</dt>
61
+ <dd v-if="result.metadataCapId" class="mono">{{ result.metadataCapId }}</dd>
62
+ <dt>Package code</dt>
63
+ <dd>{{ result.upgradeCapId ? 'Upgradeable' : 'Immutable (UpgradeCap burned)' }}</dd>
64
+ </dl>
65
+
66
+ <div class="row">
67
+ <button type="button" class="primary" :disabled="downloading" @click="downloadPackage">
68
+ <span v-if="downloading" class="spinner" aria-hidden="true"></span>
69
+ Download source package (.zip)
70
+ </button>
71
+ <button type="button" @click="$emit('restart')">Deploy another</button>
72
+ </div>
73
+ <AppNotice v-if="downloadError" type="error">{{ downloadError }}</AppNotice>
74
+
75
+ <div style="margin-top: 1rem">
76
+ <GithubPush :config="config" :result="result" />
77
+ </div>
78
+
79
+ <p v-once class="hint" style="margin-top: 1rem">
80
+ The download is the full, buildable Move package (matching this on-chain deployment) — your
81
+ TreasuryCap grants minting rights; keep it safe. This tool takes no custody of your token.
82
+ </p>
83
+ <p v-once class="hint">
84
+ You are the sole author of this token. This tool does not review or endorse it, and its
85
+ appearance here is not a mark of legitimacy.
86
+ </p>
87
+ </section>
88
+ </template>