-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathdeploy.ts
More file actions
344 lines (289 loc) · 12.2 KB
/
Copy pathdeploy.ts
File metadata and controls
344 lines (289 loc) · 12.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
import fs from 'node:fs'
import { ARIO, SolanaANTWriteable } from '@ar.io/sdk'
import { Command } from '@oclif/core'
import ora from 'ora'
import { type DeployConfig, deployFlagConfigs } from '../constants/flags.js'
import { promptAdvancedOptions, promptUpdateArns } from '../prompts/arns.js'
import { getWalletConfig } from '../prompts/wallet.js'
import { chalk } from '../utils/chalk.js'
import { extractFlags, resolveConfig } from '../utils/config-resolver.js'
import { deployKeyFromPrivateKey, deployKeyFromWalletFile } from '../utils/deploy-key.js'
import { type DisplayRow, formatDisplayRows, formatUploadError } from '../utils/display.js'
import { expandPath } from '../utils/path.js'
import {
clusterProgramIds,
createArioRpc,
createArioRpcSubscriptions,
createSolanaArnsSigner,
type SolanaCluster,
} from '../utils/solana.js'
import { runUploadWorkflow } from '../workflows/upload-workflow.js'
export default class Deploy extends Command {
static override args = {}
static override description = 'Deploy an application to the permaweb with optional ArNS update'
static override examples = [
'<%= config.bin %> deploy --wallet ./wallet.json',
'<%= config.bin %> deploy --wallet ./wallet.json --deploy-folder ./dist',
'<%= config.bin %> deploy --wallet ./wallet.json --deploy-file ./dist/index.html',
'<%= config.bin %> deploy --wallet ./wallet.json --use-arns --arns-name my-app --arns-wallet ./arns-id.json',
'<%= config.bin %> deploy --wallet ./wallet.json --use-arns --arns-name my-app --arns-wallet ./arns-id.json --undername staging',
]
static override flags = extractFlags(deployFlagConfigs)
public async run(): Promise<void> {
try {
const { flags } = await this.parse(Deploy)
const hasArnsName = Boolean(flags['arns-name'])
const explicitUseArns = Boolean(flags['use-arns'])
const canPrompt = Boolean(process.stdout.isTTY) && !process.env.CI
// Decide whether to update ArNS and whether to run interactive prompts.
// When no ArNS details are supplied we ask by default (in a TTY); the
// resolveConfig pass below then prompts for the name and other missing
// values. A non-interactive environment falls back to upload-only.
let useArns = hasArnsName || explicitUseArns
let interactive = false
if (hasArnsName) {
interactive = false
} else if (explicitUseArns) {
interactive = canPrompt
} else if (canPrompt) {
useArns = await promptUpdateArns()
interactive = useArns
}
if (interactive) {
this.log(chalk.bold(chalk.cyan('\nInteractive Deployment Mode\n')))
if (useArns) {
this.log(
chalk.dim(
'Two keys are used:\n' +
' • Upload key — pays for the upload (any supported chain)\n' +
' • ArNS authority key — a Solana key that controls the name and signs the update\n',
),
)
}
}
const baseConfig = (await resolveConfig<typeof deployFlagConfigs>(deployFlagConfigs, flags, {
interactive,
})) as DeployConfig
let walletConfig: { privateKey?: string; wallet?: string } = {
privateKey: baseConfig['private-key'],
wallet: baseConfig.wallet,
}
const shouldPromptWallet =
canPrompt &&
!baseConfig.wallet &&
!baseConfig['private-key'] &&
(interactive || !process.env.DEPLOY_KEY?.trim())
if (shouldPromptWallet) {
const config = await getWalletConfig({
envVar: 'DEPLOY_KEY',
label: 'upload key',
purpose: 'pays for the upload',
})
walletConfig = {
privateKey: config.privateKey,
wallet: config.wallet,
}
}
// ArNS authority key — separate from the upload key. Always a Solana key
// that controls the ArNS name and signs the ANT record update. Only needed
// when updating ArNS.
let arnsKeyConfig: { privateKey?: string; wallet?: string } = {
privateKey: baseConfig['arns-private-key'],
wallet: baseConfig['arns-wallet'],
}
const shouldPromptArnsKey =
canPrompt &&
useArns &&
!arnsKeyConfig.wallet &&
!arnsKeyConfig.privateKey &&
(interactive || !process.env.ARNS_KEY?.trim())
if (shouldPromptArnsKey) {
const config = await getWalletConfig({
envVar: 'ARNS_KEY',
fileDefault: './arns-wallet.json',
label: 'ArNS authority key',
purpose: 'controls the ArNS name and signs the record update',
})
arnsKeyConfig = {
privateKey: config.privateKey,
wallet: config.wallet,
}
}
let advancedOptions:
| {
cluster: string
maxTokenAmount?: string
onDemand?: string
ttlSeconds: string
undername: string
}
| undefined
if (interactive) {
const options = await promptAdvancedOptions()
advancedOptions = options || undefined
}
const effectiveCacheMaxEntries = baseConfig['no-dedupe']
? 0
: baseConfig['dedupe-cache-max-entries']
const deployConfig: DeployConfig = {
'arns-name': baseConfig['arns-name'],
'arns-private-key': arnsKeyConfig.privateKey,
'arns-wallet': arnsKeyConfig.wallet,
cluster: advancedOptions?.cluster || baseConfig.cluster,
'dedupe-cache-max-entries': effectiveCacheMaxEntries,
'deploy-file': baseConfig['deploy-file'],
'deploy-folder': baseConfig['deploy-folder'],
'fallback-file': baseConfig['fallback-file'],
'max-token-amount': advancedOptions?.maxTokenAmount || baseConfig['max-token-amount'],
'no-dedupe': baseConfig['no-dedupe'],
'on-demand': advancedOptions?.onDemand || baseConfig['on-demand'],
'private-key': walletConfig.privateKey,
'rpc-url': baseConfig['rpc-url'],
'sig-type': baseConfig['sig-type'],
'ttl-seconds': advancedOptions?.ttlSeconds || baseConfig['ttl-seconds'],
undername: advancedOptions?.undername || baseConfig.undername,
uploader: baseConfig.uploader,
'use-arns': useArns,
wallet: walletConfig.wallet,
}
if (interactive) {
this.log('')
}
// Resolve a deploy key from a wallet file, a private-key string, or an
// environment variable. Used for both the upload key and the (Solana)
// ArNS authority key — they are independent inputs.
const resolveKey = (key: {
envVar: string
missing: string
privateKey?: string
sigType: string
walletPath?: string
}): string => {
if (key.walletPath) {
const resolvedPath = expandPath(key.walletPath)
if (!fs.existsSync(resolvedPath)) {
this.error(`Wallet file [${key.walletPath}] does not exist`)
}
return deployKeyFromWalletFile(key.sigType, fs.readFileSync(resolvedPath, 'utf8'))
}
if (key.privateKey) {
return deployKeyFromPrivateKey(key.sigType, key.privateKey)
}
const envValue = process.env[key.envVar]?.trim()
if (envValue) {
return envValue
}
return this.error(key.missing)
}
const deployKey = resolveKey({
envVar: 'DEPLOY_KEY',
missing:
'No upload key provided. Use --wallet, --private-key, or set DEPLOY_KEY (the key that pays for the upload).',
privateKey: deployConfig['private-key'],
sigType: deployConfig['sig-type'],
walletPath: deployConfig.wallet,
})
// ArNS authority key is always a Solana key, independent of the upload key.
const arnsAuthorityKey = deployConfig['use-arns']
? resolveKey({
envVar: 'ARNS_KEY',
missing:
'No ArNS authority key provided. Use --arns-wallet, --arns-private-key, or set ARNS_KEY (the Solana key that controls the ArNS name).',
privateKey: deployConfig['arns-private-key'],
sigType: 'solana',
walletPath: deployConfig['arns-wallet'],
})
: ''
this.log(chalk.bold(chalk.cyan('\nStarting deployment...\n')))
try {
if (!deployConfig['use-arns']) {
const { transactionId: txOrManifestId } = await runUploadWorkflow(
deployKey,
deployConfig,
{
error: (msg) => this.error(msg),
},
)
this.log('')
const rows: DisplayRow[] = [['Tx ID', chalk.green(txOrManifestId)]]
if (deployConfig.uploader) {
rows.push(['Bundler service', chalk.cyan(deployConfig.uploader)])
}
rows.push(['Arweave URL', chalk.yellow(`https://turbo-gateway.com/${txOrManifestId}`)])
this.log(chalk.bold(chalk.green('Deployment Successful!')))
this.log(formatDisplayRows(rows))
return
}
const cluster = deployConfig.cluster as SolanaCluster
const rpcUrl = deployConfig['rpc-url']
const arnsName = deployConfig['arns-name']
if (!arnsName) {
this.error('--use-arns requires --arns-name')
}
const spinner = ora()
spinner.start('Initializing ARIO')
const programIds = clusterProgramIds(cluster)
const rpc = createArioRpc(cluster, rpcUrl)
const ario = ARIO.init({ rpc, ...programIds })
spinner.succeed('ARIO initialized')
spinner.start(`Fetching ArNS record for ${chalk.yellow(arnsName)}`)
const arnsNameRecord = await ario.getArNSRecord({ name: arnsName }).catch(() => {
spinner.fail(`ArNS name ${chalk.red(arnsName)} does not exist`)
this.error(`ArNS name [${arnsName}] does not exist`)
})
spinner.succeed(`ArNS record fetched for ${chalk.green(arnsName)}`)
const { transactionId: txOrManifestId } = await runUploadWorkflow(deployKey, deployConfig, {
error: (msg) => this.error(msg),
})
this.log('')
spinner.start('Updating ANT record')
const signer = await createSolanaArnsSigner(arnsAuthorityKey)
const ant = new SolanaANTWriteable({
processId: arnsNameRecord.processId,
rpc,
rpcSubscriptions: createArioRpcSubscriptions(cluster, rpcUrl),
signer,
...(programIds.antProgramId ? { antProgramId: programIds.antProgramId } : {}),
})
const recordParams = {
transactionId: txOrManifestId,
ttlSeconds: Number.parseInt(deployConfig['ttl-seconds'], 10),
}
await (deployConfig.undername === '@'
? ant.setBaseNameRecord(recordParams)
: ant.setUndernameRecord({ ...recordParams, undername: deployConfig.undername }))
spinner.succeed('ANT record updated')
const rows: DisplayRow[] = [['Tx ID', chalk.green(txOrManifestId)]]
if (deployConfig.uploader) {
rows.push(['Bundler service', chalk.cyan(deployConfig.uploader)])
}
rows.push(
['ArNS Name', chalk.yellow(arnsName)],
['Undername', chalk.yellow(deployConfig.undername)],
['ANT', chalk.cyan(arnsNameRecord.processId)],
['Cluster', chalk.gray(cluster)],
['TTL Seconds', chalk.blue(deployConfig['ttl-seconds'])],
['Arweave URL', chalk.yellow(`https://turbo-gateway.com/${txOrManifestId}`)],
)
this.log(chalk.bold(chalk.green('Deployment Successful!')))
this.log(formatDisplayRows(rows))
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error)
const normalizedError = errorMessage.startsWith('Upload failed:')
? errorMessage.replace(/^Upload failed:\s*/, '')
: errorMessage
if (errorMessage.startsWith('Upload failed:') && !process.env.CI && process.stdout.isTTY) {
this.log(`\n${formatUploadError(normalizedError, 'Deployment failed')}`)
this.exit(1)
}
this.error(chalk.red(`Deployment failed: ${errorMessage}`))
}
} catch (error) {
if (error instanceof Error && error.name === 'ExitPromptError') {
this.log(chalk.yellow('\n\nDeployment cancelled'))
this.exit(0)
}
throw error
}
}
}