@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,243 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import type { Transaction } from '@mysten/sui/transactions'
3
+ import { fromBase64 } from '@mysten/sui/utils'
4
+ import {
5
+ buildPublishTransaction,
6
+ buildFinalizeTransaction,
7
+ needsFinalize,
8
+ extractPublishResult,
9
+ } from '../src/lib/buildPublishTx.js'
10
+ import type { TokenConfig } from '../src/lib/types.js'
11
+
12
+ const sender = '0x' + '1'.repeat(64)
13
+ const recipient = '0x' + '2'.repeat(64)
14
+ const coinType = '0xPKG::mytoken::MYTOKEN'
15
+
16
+ // ─── introspection helpers over Transaction.getData() ─────────────────────────
17
+
18
+ type Cmd = ReturnType<Transaction['getData']>['commands'][number]
19
+
20
+ function commands(tx: Transaction): Cmd[] {
21
+ return tx.getData().commands
22
+ }
23
+
24
+ function byKind(tx: Transaction, kind: string): Cmd[] {
25
+ return commands(tx).filter((c) => c.$kind === kind)
26
+ }
27
+
28
+ /** Resolve an `{ Input: n }` argument to its decoded Pure bytes. */
29
+ function pureBytesOfInput(tx: Transaction, arg: unknown): Uint8Array {
30
+ const idx = (arg as { $kind: string; Input: number }).Input
31
+ const input = tx.getData().inputs[idx] as { $kind: string; Pure?: { bytes: string } }
32
+ if (input.$kind !== 'Pure' || !input.Pure) throw new Error('expected a Pure input')
33
+ return fromBase64(input.Pure.bytes)
34
+ }
35
+
36
+ function pureAddr(bytes: Uint8Array): string {
37
+ return '0x' + Array.from(bytes).map((b) => b.toString(16).padStart(2, '0')).join('')
38
+ }
39
+
40
+ function pureU64(bytes: Uint8Array): bigint {
41
+ return new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength).getBigUint64(0, true)
42
+ }
43
+
44
+ function baseConfig(over: Partial<TokenConfig> = {}): TokenConfig {
45
+ return {
46
+ packageName: 'my_token', moduleName: 'mytoken', structName: 'MYTOKEN',
47
+ symbol: 'MTK', name: 'My Token', description: 'desc', iconUrl: '', decimals: 9,
48
+ initialSupply: 0n, supplyPolicy: 'mintable', metadataPolicy: 'updatable',
49
+ packagePolicy: 'immutable', recipient: sender, license: 'MIT',
50
+ packageDescription: '', projectName: '',
51
+ ...over,
52
+ }
53
+ }
54
+
55
+ const publishArgs = (over: Partial<Parameters<typeof buildPublishTransaction>[0]> = {}) => ({
56
+ moduleBytes: new Uint8Array([1, 2, 3]),
57
+ sender,
58
+ feeMist: 1_000_000_000n,
59
+ feeRecipient: recipient,
60
+ gasBudget: 500_000_000n,
61
+ packagePolicy: 'immutable' as const,
62
+ ...over,
63
+ })
64
+
65
+ // ─── buildPublishTransaction ──────────────────────────────────────────────────
66
+
67
+ describe('buildPublishTransaction', () => {
68
+ it('publishes the module and sets sender + gas budget', () => {
69
+ const tx = buildPublishTransaction(publishArgs())
70
+ const publish = byKind(tx, 'Publish')
71
+ expect(publish).toHaveLength(1)
72
+ expect(tx.getData().sender).toBe(sender)
73
+ expect(tx.getData().gasData.budget).toBe('500000000')
74
+ })
75
+
76
+ it('splits EXACTLY the fee from the gas coin to the configured treasury', () => {
77
+ const tx = buildPublishTransaction(publishArgs({ feeMist: 1_000_000_000n }))
78
+ const [split] = byKind(tx, 'SplitCoins') as Array<Cmd & { SplitCoins: any }>
79
+ expect(split).toBeDefined()
80
+ // must split from the gas coin, not an arbitrary coin
81
+ expect(split.SplitCoins.coin.$kind).toBe('GasCoin')
82
+ expect(pureU64(pureBytesOfInput(tx, split.SplitCoins.amounts[0]))).toBe(1_000_000_000n)
83
+
84
+ const [transfer] = byKind(tx, 'TransferObjects') as Array<Cmd & { TransferObjects: any }>
85
+ expect(pureAddr(pureBytesOfInput(tx, transfer.TransferObjects.address))).toBe(recipient)
86
+ })
87
+
88
+ it('omits the fee split entirely when feeMist is 0', () => {
89
+ const tx = buildPublishTransaction(publishArgs({ feeMist: 0n }))
90
+ expect(byKind(tx, 'SplitCoins')).toHaveLength(0)
91
+ })
92
+
93
+ it('makes the package immutable under the immutable policy', () => {
94
+ const tx = buildPublishTransaction(publishArgs({ packagePolicy: 'immutable' }))
95
+ const calls = byKind(tx, 'MoveCall') as Array<Cmd & { MoveCall: any }>
96
+ expect(calls.some((c) => c.MoveCall.function === 'make_immutable')).toBe(true)
97
+ })
98
+
99
+ it('transfers the UpgradeCap to the sender under the upgradeable policy', () => {
100
+ const tx = buildPublishTransaction(publishArgs({ packagePolicy: 'upgradeable', feeMist: 0n }))
101
+ expect(byKind(tx, 'MoveCall')).toHaveLength(0)
102
+ const [transfer] = byKind(tx, 'TransferObjects') as Array<Cmd & { TransferObjects: any }>
103
+ // the only transfer is the UpgradeCap -> sender (no fee split here)
104
+ expect(pureAddr(pureBytesOfInput(tx, transfer.TransferObjects.address))).toBe(sender)
105
+ })
106
+ })
107
+
108
+ // ─── buildFinalizeTransaction ─────────────────────────────────────────────────
109
+
110
+ const treasuryCapId = '0x' + 'a'.repeat(64)
111
+ const metadataCapId = '0x' + 'b'.repeat(64)
112
+ const currencyRef = { objectId: '0x' + 'c'.repeat(64), version: '1', digest: 'CURRENCYDIGEST' }
113
+
114
+ const finalizeArgs = (config: TokenConfig) => ({
115
+ config,
116
+ coinType,
117
+ treasuryCapId,
118
+ metadataCapId,
119
+ currencyRef,
120
+ sender,
121
+ gasBudget: 500_000_000n,
122
+ })
123
+
124
+ describe('buildFinalizeTransaction', () => {
125
+ it('calls finalize_registration first when currencyRef is provided', () => {
126
+ const tx = buildFinalizeTransaction(finalizeArgs(baseConfig()))
127
+ const calls = byKind(tx, 'MoveCall') as Array<Cmd & { MoveCall: any }>
128
+ // finalize_registration is the first MoveCall in the PTB
129
+ expect(calls[0]?.MoveCall?.function).toBe('finalize_registration')
130
+ expect(calls[0]?.MoveCall?.typeArguments).toEqual([coinType])
131
+ })
132
+
133
+ it('omits finalize_registration when currencyRef is absent', () => {
134
+ const { currencyRef: _cr, ...argsNoCurrency } = finalizeArgs(baseConfig())
135
+ const tx = buildFinalizeTransaction(argsNoCurrency)
136
+ const calls = byKind(tx, 'MoveCall') as Array<Cmd & { MoveCall: any }>
137
+ expect(calls.every((c) => c.MoveCall.function !== 'finalize_registration')).toBe(true)
138
+ })
139
+
140
+ it('mints supply scaled by decimals to the recipient', () => {
141
+ const tx = buildFinalizeTransaction(finalizeArgs(baseConfig({ initialSupply: 1000n, decimals: 6 })))
142
+ const mint = (byKind(tx, 'MoveCall') as Array<Cmd & { MoveCall: any }>).find(
143
+ (c) => c.MoveCall.function === 'mint',
144
+ )
145
+ expect(mint).toBeDefined()
146
+ expect(mint!.MoveCall.typeArguments).toEqual([coinType])
147
+ // 1000 * 10^6
148
+ expect(pureU64(pureBytesOfInput(tx, mint!.MoveCall.arguments[1]))).toBe(1_000_000_000n)
149
+ })
150
+
151
+ it('freezes the TreasuryCap under the fixed supply policy', () => {
152
+ const tx = buildFinalizeTransaction(finalizeArgs(baseConfig({ supplyPolicy: 'fixed' })))
153
+ const freeze = (byKind(tx, 'MoveCall') as Array<Cmd & { MoveCall: any }>).filter(
154
+ (c) => c.MoveCall.function === 'public_freeze_object',
155
+ )
156
+ expect(freeze.some((c) => c.MoveCall.typeArguments[0].includes('coin::TreasuryCap'))).toBe(true)
157
+ })
158
+
159
+ it('freezes the MetadataCap under the frozen metadata policy', () => {
160
+ const tx = buildFinalizeTransaction(finalizeArgs(baseConfig({ metadataPolicy: 'frozen' })))
161
+ const freeze = (byKind(tx, 'MoveCall') as Array<Cmd & { MoveCall: any }>).filter(
162
+ (c) => c.MoveCall.function === 'public_freeze_object',
163
+ )
164
+ expect(freeze.some((c) => c.MoveCall.typeArguments[0].includes('coin_registry::MetadataCap'))).toBe(true)
165
+ })
166
+
167
+ it('routes both caps to a different recipient when supply/metadata stay open', () => {
168
+ const tx = buildFinalizeTransaction(finalizeArgs(baseConfig({ recipient })))
169
+ const transfers = byKind(tx, 'TransferObjects') as Array<Cmd & { TransferObjects: any }>
170
+ // TreasuryCap + MetadataCap both go to the recipient (no mint here)
171
+ expect(transfers).toHaveLength(2)
172
+ for (const t of transfers) {
173
+ expect(pureAddr(pureBytesOfInput(tx, t.TransferObjects.address))).toBe(recipient)
174
+ }
175
+ })
176
+
177
+ it('does not freeze when policies are open and recipient is the sender', () => {
178
+ const tx = buildFinalizeTransaction(finalizeArgs(baseConfig({ initialSupply: 5n })))
179
+ const freeze = (byKind(tx, 'MoveCall') as Array<Cmd & { MoveCall: any }>).filter(
180
+ (c) => c.MoveCall.function === 'public_freeze_object',
181
+ )
182
+ expect(freeze).toHaveLength(0)
183
+ })
184
+ })
185
+
186
+ // ─── needsFinalize ────────────────────────────────────────────────────────────
187
+
188
+ describe('needsFinalize', () => {
189
+ it('is false for the pure-default config (publish-only)', () => {
190
+ expect(needsFinalize(baseConfig(), sender)).toBe(false)
191
+ })
192
+
193
+ it('is true when any policy/supply/recipient differs from the default', () => {
194
+ expect(needsFinalize(baseConfig({ initialSupply: 1n }), sender)).toBe(true)
195
+ expect(needsFinalize(baseConfig({ supplyPolicy: 'fixed' }), sender)).toBe(true)
196
+ expect(needsFinalize(baseConfig({ metadataPolicy: 'frozen' }), sender)).toBe(true)
197
+ expect(needsFinalize(baseConfig({ recipient }), sender)).toBe(true)
198
+ })
199
+
200
+ it('treats recipient case-insensitively', () => {
201
+ expect(needsFinalize(baseConfig({ recipient: sender.toUpperCase() }), sender)).toBe(false)
202
+ })
203
+ })
204
+
205
+ // ─── extractPublishResult ─────────────────────────────────────────────────────
206
+
207
+ describe('extractPublishResult', () => {
208
+ const changes = [
209
+ { type: 'published', packageId: '0xPKG' },
210
+ { type: 'created', objectType: `0x2::coin::TreasuryCap<${coinType}>`, objectId: '0xT' },
211
+ { type: 'created', objectType: `0x2::coin_registry::MetadataCap<${coinType}>`, objectId: '0xM' },
212
+ { type: 'created', objectType: `0x2::coin_registry::Currency<${coinType}>`, objectId: '0xC', version: '7', digest: 'CDIG' },
213
+ { type: 'created', objectType: `0x2::package::UpgradeCap`, objectId: '0xU' },
214
+ ]
215
+
216
+ it('extracts package id, coin type, all cap ids, and currency version/digest', () => {
217
+ const r = extractPublishResult(changes, {
218
+ network: 'testnet', digest: '0xD', feeRecipient: recipient, feeMist: 1_000_000_000n,
219
+ })
220
+ expect(r.packageId).toBe('0xPKG')
221
+ expect(r.coinType).toBe(coinType)
222
+ expect(r.treasuryCapId).toBe('0xT')
223
+ expect(r.metadataCapId).toBe('0xM')
224
+ expect(r.currencyId).toBe('0xC')
225
+ expect(r.currencyVersion).toBe('7')
226
+ expect(r.currencyDigest).toBe('CDIG')
227
+ expect(r.upgradeCapId).toBe('0xU')
228
+ expect(r.feeRecipient).toBe(recipient)
229
+ expect(r.feeMist).toBe('1000000000')
230
+ })
231
+
232
+ it('leaves the upgrade cap undefined for an immutable publish', () => {
233
+ const immutable = changes.filter((c) => !(c.objectType ?? '').includes('UpgradeCap'))
234
+ const r = extractPublishResult(immutable, {
235
+ network: 'mainnet', digest: '0xD', feeRecipient: recipient, feeMist: 0n,
236
+ })
237
+ expect(r.upgradeCapId).toBeUndefined()
238
+ expect(r.coinType).toBe(coinType)
239
+ // currency ref still extracted
240
+ expect(r.currencyVersion).toBe('7')
241
+ expect(r.currencyDigest).toBe('CDIG')
242
+ })
243
+ })
@@ -0,0 +1,81 @@
1
+ import { describe, it, expect, vi } from 'vitest'
2
+ import { deployToken } from '../src/lib/deploy.js'
3
+ import type { Executor, SuiTxResult } from '../src/lib/deploy.js'
4
+ import type { TokenConfig } from '../src/lib/types.js'
5
+
6
+ const sender = '0x' + '1'.repeat(64)
7
+ const treasury = '0x' + '2'.repeat(64)
8
+
9
+ function baseConfig(over: Partial<TokenConfig> = {}): TokenConfig {
10
+ return {
11
+ packageName: 'my_token', moduleName: 'mytoken', structName: 'IGNORED',
12
+ symbol: 'MTK', name: 'My Token', description: 'desc', iconUrl: '', decimals: 9,
13
+ initialSupply: 0n, supplyPolicy: 'mintable', metadataPolicy: 'updatable', packagePolicy: 'immutable',
14
+ recipient: sender, license: 'MIT', packageDescription: '', projectName: '',
15
+ ...over,
16
+ }
17
+ }
18
+
19
+ const publishChanges = (coinType: string): SuiTxResult['objectChanges'] => [
20
+ { type: 'published', packageId: '0xPKG' },
21
+ { type: 'created', objectType: `0x2::coin::TreasuryCap<${coinType}>`, objectId: '0xT' },
22
+ { type: 'created', objectType: `0x2::coin_registry::MetadataCap<${coinType}>`, objectId: '0xM' },
23
+ // version + digest are present in real Sui RPC responses and required for finalize_registration
24
+ { type: 'created', objectType: `0x2::coin_registry::Currency<${coinType}>`, objectId: '0xC', version: '1', digest: 'CURRENCYDIGEST' },
25
+ ]
26
+
27
+ function mockExecutor(overrides: Partial<Executor> = {}): Executor {
28
+ const coinType = '0xPKG::mytoken::MYTOKEN'
29
+ return {
30
+ signAndExecute: vi.fn<any>(async () => ({
31
+ digest: '0xDIGEST',
32
+ objectChanges: publishChanges(coinType),
33
+ effects: { status: { status: 'success' } },
34
+ })) as any,
35
+ waitForTransaction: vi.fn<any>(async () => {}) as any,
36
+ ...overrides,
37
+ } as any
38
+ }
39
+
40
+ describe('deployToken', () => {
41
+ it('always runs a finalize tx (for coin_registry::finalize_registration); struct name is derived', async () => {
42
+ const exec = mockExecutor()
43
+ const steps: string[] = []
44
+ const result = await deployToken({
45
+ config: baseConfig(), network: 'testnet', sender, feeMist: 500_000_000n, feeTreasury: treasury,
46
+ gasBudget: 500_000_000n, executor: exec, onStep: (s) => steps.push(s),
47
+ })
48
+ expect(result.coinType).toBe('0xPKG::mytoken::MYTOKEN')
49
+ expect(result.packageId).toBe('0xPKG')
50
+ expect(result.treasuryCapId).toBe('0xT')
51
+ // publish + finalize (finalize_registration is always required)
52
+ expect((exec.signAndExecute as ReturnType<typeof vi.fn>).mock.calls.length).toBe(2)
53
+ expect(steps).toEqual(['patching', 'publishing', 'confirming', 'finalizing', 'confirming-finalize', 'done'])
54
+ })
55
+
56
+ it('runs a finalize tx for initial supply as well', async () => {
57
+ const exec = mockExecutor()
58
+ const steps: string[] = []
59
+ await deployToken({
60
+ config: baseConfig({ initialSupply: 1000n }), network: 'testnet', sender,
61
+ feeMist: 0n, feeTreasury: treasury, gasBudget: 500_000_000n, executor: exec,
62
+ onStep: (s) => steps.push(s),
63
+ })
64
+ expect((exec.signAndExecute as ReturnType<typeof vi.fn>).mock.calls.length).toBe(2)
65
+ expect(steps).toContain('finalizing')
66
+ })
67
+
68
+ it('throws with the effects error when publish fails', async () => {
69
+ const exec = mockExecutor({
70
+ signAndExecute: vi.fn<any>(async () => ({
71
+ digest: '0xD', objectChanges: [], effects: { status: { status: 'failure', error: 'InsufficientGas' } },
72
+ })) as any,
73
+ })
74
+ await expect(
75
+ deployToken({
76
+ config: baseConfig(), network: 'testnet', sender, feeMist: 0n, feeTreasury: treasury,
77
+ gasBudget: 1n, executor: exec,
78
+ }),
79
+ ).rejects.toThrow(/InsufficientGas/)
80
+ })
81
+ })
@@ -0,0 +1,32 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { extractErrorMessage } from '../src/lib/errors.js'
3
+
4
+ describe('extractErrorMessage', () => {
5
+ it('returns the message from a standard Error', () => {
6
+ expect(extractErrorMessage(new Error('something went wrong'))).toBe('something went wrong')
7
+ })
8
+
9
+ it('returns a plain string unchanged', () => {
10
+ expect(extractErrorMessage('oops')).toBe('oops')
11
+ })
12
+
13
+ it('extracts message from a plain object — wallet rejection pattern', () => {
14
+ expect(extractErrorMessage({ code: 4001, message: 'User rejected the request.' }))
15
+ .toBe('User rejected the request.')
16
+ })
17
+
18
+ it('falls back to cause when Error.message is empty', () => {
19
+ const e = new Error()
20
+ ;(e as any).cause = new Error('nested cause')
21
+ expect(extractErrorMessage(e)).toBe('nested cause')
22
+ })
23
+
24
+ it('extracts cause.message from a plain object with nested cause', () => {
25
+ expect(extractErrorMessage({ cause: { message: 'inner error' } })).toBe('inner error')
26
+ })
27
+
28
+ it('falls back to String() for unrecognised values', () => {
29
+ expect(extractErrorMessage(42)).toBe('42')
30
+ expect(extractErrorMessage(null)).toBe('null')
31
+ })
32
+ })
@@ -0,0 +1,182 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { unzipSync, strFromU8 } from 'fflate'
3
+ import { buildPackageFiles, generatePackageZip } from '../src/lib/generatePackage.js'
4
+ import type { PublishResult, TokenConfig } from '../src/lib/types.js'
5
+
6
+ const baseConfig: TokenConfig = {
7
+ packageName: 'my_token',
8
+ moduleName: 'mytoken',
9
+ structName: 'MYTOKEN',
10
+ symbol: 'MTK',
11
+ name: 'My Token',
12
+ description: 'A friendly token',
13
+ iconUrl: 'https://example.com/icon.png',
14
+ decimals: 8,
15
+ initialSupply: 0n,
16
+ supplyPolicy: 'mintable',
17
+ metadataPolicy: 'updatable',
18
+ packagePolicy: 'immutable',
19
+ recipient: '0xabc',
20
+ license: 'MIT',
21
+ licenseName: 'MIT License',
22
+ packageDescription: 'Official MYTOKEN token.',
23
+ projectName: 'My Project',
24
+ }
25
+
26
+ const REQUIRED = [
27
+ 'Move.toml',
28
+ 'sources/mytoken.move',
29
+ 'scripts/publish.sh',
30
+ '.gitignore',
31
+ 'README.md',
32
+ 'deployments.md',
33
+ 'CLAUDE.md',
34
+ 'AGENTS.md',
35
+ ]
36
+
37
+ describe('buildPackageFiles', () => {
38
+ it('produces the full mwsui_token-shaped file set (+ LICENSE for a real license)', () => {
39
+ const f = buildPackageFiles({ config: baseConfig, licenseText: 'MIT LICENSE\n...' })
40
+ for (const p of REQUIRED) expect(Object.keys(f)).toContain(p)
41
+ expect(f['LICENSE']).toBe('MIT LICENSE\n...')
42
+ })
43
+
44
+ it('leaves no unresolved placeholders or template identifiers', () => {
45
+ const f = buildPackageFiles({ config: baseConfig, licenseText: 'x' })
46
+ for (const [path, content] of Object.entries(f)) {
47
+ if (path === 'LICENSE') continue
48
+ expect(content, `${path} X…X`).not.toMatch(/X[A-Z_]+X/)
49
+ expect(content, `${path} sui_token_template`).not.toContain('sui_token_template')
50
+ expect(content, `${path} TEMPLATE_ default`).not.toMatch(/TEMPLATE_(SYMBOL|NAME|DESCRIPTION|ICON_URL)/)
51
+ }
52
+ })
53
+
54
+ it('substitutes the Move source identifiers and named constants', () => {
55
+ const src = buildPackageFiles({ config: baseConfig })['sources/mytoken.move']
56
+ expect(src).toContain('module my_token::mytoken;')
57
+ expect(src).toContain('const DECIMALS: u8 = 8;')
58
+ expect(src).toContain('b"MTK"')
59
+ expect(src).toContain('b"My Token"')
60
+ expect(src).toContain('b"https://example.com/icon.png"')
61
+ expect(src).toContain('SPDX-License-Identifier: MIT')
62
+ expect(src).toContain('MYTOKEN')
63
+ })
64
+
65
+ it('sets the Move.toml license field', () => {
66
+ expect(buildPackageFiles({ config: baseConfig })['Move.toml']).toMatch(/^license = "MIT"$/m)
67
+ })
68
+
69
+ it('handles a proprietary (NONE) license: no LICENSE file, UNLICENSED fields', () => {
70
+ const cfg = { ...baseConfig, license: 'NONE' }
71
+ const f = buildPackageFiles({ config: cfg, licenseText: 'ignored' })
72
+ expect(f['LICENSE']).toBeUndefined()
73
+ expect(f['Move.toml']).toMatch(/^license = "UNLICENSED"$/m)
74
+ expect(f['sources/mytoken.move']).toContain('SPDX-License-Identifier: UNLICENSED')
75
+ expect(f['README.md']).toContain('All rights reserved')
76
+ })
77
+
78
+ it('refuses to generate from an unsafe config (defence in depth)', () => {
79
+ // Values that bypass the form validator must still be rejected here so no
80
+ // injectable Move source or shell script can ever be produced.
81
+ expect(() => buildPackageFiles({ config: { ...baseConfig, symbol: 'E\nVIL' } })).toThrow(/symbol/)
82
+ expect(() => buildPackageFiles({ config: { ...baseConfig, name: 'a"b' } })).toThrow(/name/)
83
+ expect(() => buildPackageFiles({ config: { ...baseConfig, packageName: 'Bad-Name' } })).toThrow(/package name/)
84
+ expect(() => buildPackageFiles({ config: { ...baseConfig, moduleName: 'module' } })).toThrow(/module name/)
85
+ expect(() => buildPackageFiles({ config: { ...baseConfig, structName: 'WRONG' } })).toThrow(/struct name/)
86
+ expect(() => buildPackageFiles({ config: { ...baseConfig, decimals: 99 } })).toThrow(/decimals/)
87
+ expect(() => buildPackageFiles({ config: { ...baseConfig, license: 'MIT; rm -rf' } })).toThrow(/license/)
88
+ // icon-URL scheme allowlist re-asserted downstream (layered on the form check)
89
+ expect(() => buildPackageFiles({ config: { ...baseConfig, iconUrl: 'javascript:alert(1)' } })).toThrow(/icon URL/)
90
+ expect(() => buildPackageFiles({ config: { ...baseConfig, iconUrl: 'http://x.io/a.png' } })).toThrow(/icon URL/)
91
+ })
92
+
93
+ it('pre-fills deployments.md table in-place for the deployed network', () => {
94
+ const result: PublishResult = {
95
+ network: 'testnet', packageId: '0xPKG', coinType: '0xPKG::mytoken::MYTOKEN',
96
+ treasuryCapId: '0xT', metadataCapId: '0xM', currencyId: '0xC', upgradeCapId: undefined,
97
+ digest: '0xDIG', feeRecipient: '0xFEE', feeMist: '500000000',
98
+ }
99
+ const dep = buildPackageFiles({ config: baseConfig, result })['deployments.md']
100
+ // Package ID, caps, and currency are in the Testnet table
101
+ expect(dep).toContain('| Package ID | `0xPKG` |')
102
+ expect(dep).toContain('| TreasuryCap ID | `0xT` |')
103
+ expect(dep).toContain('| MetadataCap ID | `0xM` |')
104
+ expect(dep).toContain('| Currency object ID | `0xC` |')
105
+ // UpgradeCap burned: cell shows "burned", immutability confirmed: Yes
106
+ expect(dep).toContain('| UpgradeCap ID (burned) | `burned` |')
107
+ expect(dep).toContain('| Immutability confirmed | Yes |')
108
+ // Mainnet section is NOT filled (still has placeholders)
109
+ const mainnetStart = dep.indexOf('## Mainnet')
110
+ expect(dep.slice(mainnetStart)).toContain('FILL_IN_AFTER_DEPLOY')
111
+ // Coin type and digest appear in the appended comment
112
+ expect(dep).toContain('<!-- Coin type: 0xPKG::mytoken::MYTOKEN -->')
113
+ expect(dep).toContain('<!-- Publish digest: 0xDIG -->')
114
+ // Package ID fills the downstream Move.toml reference block
115
+ expect(dep).toContain('mytoken = "0xPKG"')
116
+ })
117
+
118
+ it('shows pending immutability when upgradeCapId is present', () => {
119
+ const result: PublishResult = {
120
+ network: 'testnet', packageId: '0xPKG', coinType: '0xPKG::mytoken::MYTOKEN',
121
+ upgradeCapId: '0xUCAP',
122
+ digest: '0xDIG', feeRecipient: '0xFEE', feeMist: '500000000',
123
+ }
124
+ const dep = buildPackageFiles({ config: baseConfig, result })['deployments.md']
125
+ expect(dep).toContain('| UpgradeCap ID (burned) | `0xUCAP` |')
126
+ expect(dep).toContain('| Immutability confirmed | No (pending) |')
127
+ })
128
+
129
+ it('includes Published.toml when a result is given', () => {
130
+ const result: PublishResult = {
131
+ network: 'testnet', packageId: '0xPKG', coinType: '0xPKG::mytoken::MYTOKEN',
132
+ upgradeCapId: undefined,
133
+ digest: '0xDIG', feeRecipient: '0xFEE', feeMist: '500000000',
134
+ }
135
+ const f = buildPackageFiles({ config: baseConfig, result })
136
+ const pub = f['Published.toml']
137
+ expect(pub).toBeDefined()
138
+ expect(pub).toContain('[published.testnet]')
139
+ expect(pub).toContain('chain-id = "4c78adac"')
140
+ expect(pub).toContain('original-id = "0xPKG"')
141
+ expect(pub).toContain('published-at = "0xPKG"')
142
+ expect(pub).toContain('version = 1')
143
+ // No upgrade-capability line when cap is burned
144
+ expect(pub).not.toContain('upgrade-capability =')
145
+ expect(pub).toContain('make_immutable')
146
+ })
147
+
148
+ it('Published.toml includes upgrade-capability when cap is not burned', () => {
149
+ const result: PublishResult = {
150
+ network: 'testnet', packageId: '0xPKG', coinType: '0xPKG::mytoken::MYTOKEN',
151
+ upgradeCapId: '0xUCAP',
152
+ digest: '0xDIG', feeRecipient: '0xFEE', feeMist: '500000000',
153
+ }
154
+ const f = buildPackageFiles({ config: baseConfig, result })
155
+ expect(f['Published.toml']).toContain('upgrade-capability = "0xUCAP"')
156
+ })
157
+
158
+ it('Published.toml uses mainnet chain-id for mainnet results', () => {
159
+ const result: PublishResult = {
160
+ network: 'mainnet', packageId: '0xPKG', coinType: '0xPKG::mytoken::MYTOKEN',
161
+ digest: '0xDIG', feeRecipient: '0xFEE', feeMist: '500000000',
162
+ }
163
+ const f = buildPackageFiles({ config: baseConfig, result })
164
+ expect(f['Published.toml']).toContain('[published.mainnet]')
165
+ expect(f['Published.toml']).toContain('chain-id = "35834a8a"')
166
+ })
167
+
168
+ it('omits Published.toml when no result is given', () => {
169
+ const f = buildPackageFiles({ config: baseConfig })
170
+ expect(f['Published.toml']).toBeUndefined()
171
+ })
172
+ })
173
+
174
+ describe('generatePackageZip', () => {
175
+ it('zips under a package-named root and round-trips', () => {
176
+ const zip = generatePackageZip({ config: baseConfig, licenseText: 'MIT' })
177
+ const entries = unzipSync(zip)
178
+ expect(Object.keys(entries)).toContain('my_token/Move.toml')
179
+ expect(Object.keys(entries)).toContain('my_token/sources/mytoken.move')
180
+ expect(strFromU8(entries['my_token/Move.toml'])).toContain('name = "my_token"')
181
+ })
182
+ })
@@ -0,0 +1,61 @@
1
+ import { describe, it, expect, vi } from 'vitest'
2
+ import { createRepoAndPush, toBase64Utf8 } from '../src/lib/github.js'
3
+
4
+ function jsonRes(body: unknown, ok = true, status = 200): Response {
5
+ return { ok, status, json: async () => body } as Response
6
+ }
7
+
8
+ describe('toBase64Utf8', () => {
9
+ it('encodes UTF-8 correctly', () => {
10
+ expect(toBase64Utf8('hello')).toBe('aGVsbG8=')
11
+ // round-trips multibyte
12
+ expect(Buffer.from(toBase64Utf8('héllo—'), 'base64').toString('utf8')).toBe('héllo—')
13
+ })
14
+ })
15
+
16
+ describe('createRepoAndPush', () => {
17
+ it('creates the repo then PUTs each file, reporting progress', async () => {
18
+ const fetcher = vi.fn<(url: string, _init?: RequestInit) => Promise<Response>>(async (url: string, _init?: RequestInit): Promise<Response> => {
19
+ if (url.endsWith('/user/repos')) {
20
+ return jsonRes({ full_name: 'alice/my_token', html_url: 'https://github.com/alice/my_token', owner: { login: 'alice' }, name: 'my_token' })
21
+ }
22
+ return jsonRes({ content: {} }, true, 201)
23
+ }) as unknown as typeof fetch
24
+
25
+ const progress: number[] = []
26
+ const res = await createRepoAndPush({
27
+ token: 'ghp_x',
28
+ repoName: 'my_token',
29
+ files: { 'Move.toml': 'a', 'sources/mytoken.move': 'b' },
30
+ fetcher,
31
+ onProgress: (d) => progress.push(d),
32
+ })
33
+
34
+ expect(res.htmlUrl).toBe('https://github.com/alice/my_token')
35
+ expect(res.owner).toBe('alice')
36
+ // 1 create + 2 file PUTs
37
+ const calls = (fetcher as unknown as { mock: { calls: [string, RequestInit][] } }).mock.calls
38
+ expect(calls.length).toBe(3)
39
+ expect(calls[0][1]?.method).toBe('POST')
40
+ expect(calls[1][1]?.method).toBe('PUT')
41
+ expect(progress).toEqual([1, 2])
42
+ })
43
+
44
+ it('surfaces the GitHub error message on repo-create failure', async () => {
45
+ const fetcher = vi.fn<() => Promise<Response>>(async (): Promise<Response> => jsonRes({ message: 'name already exists on this account' }, false, 422)) as unknown as typeof fetch
46
+ await expect(
47
+ createRepoAndPush({ token: 't', repoName: 'dupe', files: { a: 'x' }, fetcher }),
48
+ ).rejects.toThrow(/already exists/)
49
+ })
50
+
51
+ it('sends the token as a Bearer header', async () => {
52
+ const fetcher = vi.fn<(url: string) => Promise<Response>>(async (url: string): Promise<Response> =>
53
+ url.endsWith('/user/repos')
54
+ ? jsonRes({ html_url: 'h', owner: { login: 'bob' }, name: 'r', full_name: 'bob/r' })
55
+ : jsonRes({}, true, 201),
56
+ ) as unknown as typeof fetch
57
+ await createRepoAndPush({ token: 'secret', repoName: 'r', files: { a: 'x' }, fetcher })
58
+ const headers = (fetcher as unknown as { mock: { calls: [string, RequestInit][] } }).mock.calls[0][1]?.headers as Record<string, string>
59
+ expect(headers.Authorization).toBe('Bearer secret')
60
+ })
61
+ })