Skip to content

Commit 8dc0a01

Browse files
feat: support multi scene deployment (#1355)
* feat: support multi scene deployment * feat: validate that user is not uploading the same parcels * feat: move parcel checking to another function * feat: remove unused errors * feat: handle missing delete signature and fix parcel overlap check for world deploys * feat: remove logs
1 parent 03201a6 commit 8dc0a01

File tree

4 files changed

+205
-18
lines changed

4 files changed

+205
-18
lines changed

packages/@dcl/sdk-commands/src/commands/deploy/index.ts

Lines changed: 106 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,16 @@ import { printProgressInfo, printSuccess } from '../../logic/beautiful-logs'
1212
import { getPackageJson, b64HashingFunction } from '../../logic/project-files'
1313
import { Events } from '../../components/analytics'
1414
import { Result } from 'arg'
15-
import { getAddressAndSignature, getCatalyst, sceneHasWorldCfg } from './utils'
15+
import {
16+
getAddressAndSignature,
17+
getCatalyst,
18+
sceneHasWorldCfg,
19+
buildDeleteScenesFromWorldPayload,
20+
deleteWorldScenes,
21+
fetchWorldScenes,
22+
getScenesOnOtherParcels,
23+
promptUser
24+
} from './utils'
1625
import { buildScene } from '../build'
1726
import { getValidWorkspace } from '../../logic/workspace-validations'
1827
import { LinkerResponse } from '../../linker-dapp/routes'
@@ -51,7 +60,8 @@ export const args = declareArgs({
5160
'-b': '--no-browser',
5261
'--port': Number,
5362
'-p': '--port',
54-
'--programmatic': Boolean
63+
'--programmatic': Boolean,
64+
'--multi-scene': Boolean
5565
})
5666

5767
export function help(options: Options) {
@@ -67,6 +77,7 @@ export function help(options: Options) {
6777
--skip-build Skip build before deploy
6878
--skip-validations Skip permissions verifications on the client side when deploying content
6979
--programmatic Enable programmatic mode - returns a promise that resolves when deployment completes
80+
--multi-scene Deploy alongside existing scenes in a world (additive deploy, no deletion)
7081
7182
Example:
7283
- Deploy your scene:
@@ -86,6 +97,9 @@ export async function main(options: Options): Promise<ProgrammaticDeployResult |
8697
const skipBuild = options.args['--skip-build']
8798
const linkerPort = options.args['--port']
8899
const isProgrammatic = options.args['--programmatic']
100+
const multiScene = !!options.args['--multi-scene']
101+
const autoYes = !!options.args['--yes']
102+
const targetContent = options.args['--target-content']
89103

90104
if (workspace.projects.length !== 1) {
91105
throw new CliError('DEPLOY_WORKSPACE_NOT_SUPPORTED', i18next.t('errors.deploy.workspace_not_supported'))
@@ -101,18 +115,69 @@ export async function main(options: Options): Promise<ProgrammaticDeployResult |
101115
const sceneJson = { sdkVersion, ...(await getValidSceneJson(options.components, projectRoot, { log: true })) }
102116
const coords = getBaseCoords(sceneJson)
103117
const isWorld = sceneHasWorldCfg(sceneJson)
118+
const worldName = sceneJson.worldConfiguration?.name
119+
120+
if (isWorld) {
121+
options.components.logger.info(
122+
`[DEPLOY] deploying in world:${isWorld}, multi scene world:${multiScene}, world name:${worldName}`
123+
)
124+
}
125+
104126
const trackProps: Events['Scene deploy started'] = {
105127
projectHash: b64HashingFunction(projectRoot),
106128
coords,
107129
isWorld
108130
}
131+
109132
const packageJson = await getPackageJson(options.components, projectRoot)
110133
const dependencies = Array.from(
111134
new Set([...Object.keys(packageJson.dependencies || {}), ...Object.keys(packageJson.devDependencies || {})])
112135
)
113136

114137
options.components.analytics.track('Scene deploy started', trackProps)
115138

139+
let needsDelete = false
140+
if (isWorld && !multiScene && worldName) {
141+
options.components.logger.info(`[DEPLOY] checking existing scenes for world "${worldName}"...`)
142+
try {
143+
const existingScenes = await fetchWorldScenes(options.components.logger, worldName, targetContent)
144+
145+
if (existingScenes.length > 0) {
146+
const scenesOnOtherParcels = getScenesOnOtherParcels(existingScenes, sceneJson.scene.parcels)
147+
148+
if (scenesOnOtherParcels.length > 0) {
149+
options.components.logger.warn(
150+
`World "${worldName}" has ${scenesOnOtherParcels.length} other scene(s) that will be removed:`
151+
)
152+
153+
for (const scene of scenesOnOtherParcels) {
154+
const title = scene.entity?.metadata?.display?.title || 'Untitled'
155+
const parcels = scene.parcels?.join(', ') || 'unknown parcels'
156+
options.components.logger.log(` - "${title}" at parcels ${parcels}`)
157+
}
158+
159+
options.components.logger.log('')
160+
options.components.logger.warn(
161+
'Deploying without --multi-scene will DELETE all existing scenes in world first.'
162+
)
163+
164+
if (!autoYes) {
165+
const confirmed = await promptUser('Continue? (y/N) ')
166+
if (!confirmed) {
167+
throw new CliError('DEPLOY_CANCELLED', 'Deployment cancelled by user.')
168+
}
169+
}
170+
needsDelete = true
171+
} else {
172+
options.components.logger.warn(`[DEPLOY] no existing scenes in other parcels, deployment will continue`)
173+
}
174+
}
175+
} catch (e: any) {
176+
if (e instanceof CliError) throw e
177+
options.components.logger.warn(`Could not check existing world scenes: ${e.message}`)
178+
}
179+
}
180+
116181
if (!skipBuild) {
117182
await buildScene(
118183
{ ...options, args: { '--dir': projectRoot, '--watch': false, '--production': true, _: [] } },
@@ -134,11 +199,11 @@ export async function main(options: Options): Promise<ProgrammaticDeployResult |
134199
metadata: sceneJson
135200
})
136201

137-
// Signing message
138202
const messageToSign = entityId
139203

140-
// Start the linker dapp and wait for the user to sign in (linker response).
141-
// And then close the program
204+
const deleteScenesFromWorldPayload =
205+
needsDelete && worldName ? buildDeleteScenesFromWorldPayload(worldName) : undefined
206+
142207
const awaitResponse = future<void>()
143208
const { program } = await getAddressAndSignature(
144209
options.components,
@@ -152,7 +217,8 @@ export async function main(options: Options): Promise<ProgrammaticDeployResult |
152217
linkerPort,
153218
isHttps: !!options.args['--https']
154219
},
155-
deployEntity
220+
deployEntity,
221+
deleteScenesFromWorldPayload
156222
)
157223

158224
// Programmatic mode early return
@@ -182,6 +248,40 @@ export async function main(options: Options): Promise<ProgrammaticDeployResult |
182248
// Uploading data
183249
const { client, url } = await getCatalyst(chainId, options.args['--target'], options.args['--target-content'])
184250

251+
if (needsDelete && worldName && !linkerResponse.deleteSignature) {
252+
throw new CliError(
253+
'DEPLOY_DELETE_FAILED',
254+
`Cannot delete existing scenes from "${worldName}": there's not signatur for deleting the scenes.`
255+
)
256+
}
257+
258+
if (needsDelete && worldName && linkerResponse.deleteSignature) {
259+
options.components.logger.info(`[DEPLOY] deleting scenes for "${worldName}"`)
260+
261+
try {
262+
const deleteResponse = await deleteWorldScenes(
263+
options.components,
264+
worldName,
265+
linkerResponse.deleteSignature,
266+
targetContent
267+
)
268+
269+
if (deleteResponse.ok) {
270+
options.components.logger.info(`[DEPLOY] existing scenes for "${worldName} deleted successfully"`)
271+
} else {
272+
const errorText = await deleteResponse.text()
273+
options.components.logger.info(`[DEPLOY] DELETE FAILED: status=${deleteResponse.status} body=${errorText}`)
274+
throw new CliError(
275+
'DEPLOY_DELETE_FAILED',
276+
`Failed to delete existing scenes from "${worldName}" (status ${deleteResponse.status}): ${errorText}\n`
277+
)
278+
}
279+
} catch (e: any) {
280+
if (e instanceof CliError) throw e
281+
throw new CliError('DEPLOY_DELETE_FAILED', `Error deleting existing scenes from "${worldName}": ${e.message}\n`)
282+
}
283+
}
284+
185285
printProgressInfo(options.components.logger, `Uploading data to: ${url}...`)
186286

187287
const deployData = { entityId, files: entityFiles, authChain }

packages/@dcl/sdk-commands/src/commands/deploy/utils.ts

Lines changed: 96 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1+
import * as readline from 'readline'
12
import { ChainId, Scene } from '@dcl/schemas'
3+
import { AuthChain } from '@dcl/crypto'
24
import { Lifecycle } from '@well-known-components/interfaces'
35
import { createCatalystClient, createContentClient, CatalystClient, ContentClient } from 'dcl-catalyst-client'
46
import { getCatalystServersFromCache } from 'dcl-catalyst-client/dist/contracts-snapshots'
@@ -67,6 +69,44 @@ export async function getCatalyst(
6769
}
6870
}
6971

72+
export function buildDeleteScenesFromWorldPayload(worldName: string): string {
73+
const encodedName = encodeURIComponent(worldName)
74+
const path = `/entities/${encodedName}`
75+
const timestamp = String(Date.now())
76+
const metadata = '{}'
77+
return ['delete', path, timestamp, metadata].join(':').toLowerCase()
78+
}
79+
80+
export async function deleteWorldScenes(
81+
components: Pick<CliComponents, 'logger'>,
82+
worldName: string,
83+
deleteSignature: string,
84+
targetContent?: string
85+
): Promise<Response> {
86+
const { logger } = components
87+
88+
const encodedName = encodeURIComponent(worldName)
89+
const deleteUrl = `${targetContent}/entities/${encodedName}`
90+
91+
const authChain: AuthChain = JSON.parse(deleteSignature)
92+
const lastLink = authChain[authChain.length - 1]
93+
const payloadParts = lastLink.payload.split(':')
94+
const timestamp = payloadParts[2] || String(Date.now())
95+
const metadata = payloadParts[3] || '{}'
96+
97+
const headers: Record<string, string> = {
98+
'x-identity-timestamp': timestamp,
99+
'x-identity-metadata': metadata
100+
}
101+
authChain.forEach((link, i) => {
102+
headers[`x-identity-auth-chain-${i}`] = JSON.stringify(link)
103+
})
104+
105+
const response = await fetch(deleteUrl, { method: 'DELETE', headers })
106+
logger.info(`[DELETE] deleting world scenes status=${response.status}`)
107+
return response
108+
}
109+
70110
export async function getAddressAndSignature(
71111
components: CliComponents,
72112
awaitResponse: IFuture<void>,
@@ -75,7 +115,8 @@ export async function getAddressAndSignature(
75115
files: IFile[],
76116
skipValidations: boolean,
77117
linkOptions: Omit<dAppOptions, 'uri'>,
78-
deployCallback: (response: LinkerResponse) => Promise<void>
118+
deployCallback: (response: LinkerResponse) => Promise<void>,
119+
deleteScenesFromWorldPayload?: string
79120
): Promise<{ program?: Lifecycle.ComponentBasedProgram<unknown> }> {
80121
if (process.env.DCL_PRIVATE_KEY) {
81122
const wallet = createWallet(process.env.DCL_PRIVATE_KEY)
@@ -84,13 +125,13 @@ export async function getAddressAndSignature(
84125
wallet.address,
85126
ethSign(hexToBytes(wallet.privateKey), messageToSign)
86127
)
87-
const linkerResponse = { authChain, address: wallet.address }
128+
const linkerResponse: LinkerResponse = { authChain, address: wallet.address }
88129
await deployCallback(linkerResponse)
89130
awaitResponse.resolve()
90131
return {}
91132
}
92133

93-
const sceneInfo = await getSceneInfo(components, scene, messageToSign, skipValidations)
134+
const sceneInfo = await getSceneInfo(components, scene, messageToSign, skipValidations, deleteScenesFromWorldPayload)
94135
const { router: commonRouter } = setRoutes(components, sceneInfo)
95136
const router = setDeployRoutes(commonRouter, components, awaitResponse, sceneInfo, files, deployCallback)
96137

@@ -109,7 +150,6 @@ function setDeployRoutes(
109150
): Router<object> {
110151
const { logger } = components
111152

112-
// We need to wait so the linker-dapp can receive the response and show a nice message.
113153
const resolveLinkerPromise = () => setTimeout(() => awaitResponse.resolve(), 100)
114154
const rejectLinkerPromise = (e: Error) => setTimeout(() => awaitResponse.reject(e), 100)
115155
let linkerResponse: LinkerResponse
@@ -129,8 +169,6 @@ function setDeployRoutes(
129169
const value = await getPointers(components, pointer, network)
130170
const deployedToAll = new Set(value.map((c) => c.entityId)).size === 1
131171

132-
// Deployed to every catalyst, close the linker dapp and
133-
// exit the command automatically so the user dont have to.
134172
if (deployedToAll) resolveLinkerPromise()
135173

136174
return {
@@ -140,21 +178,17 @@ function setDeployRoutes(
140178

141179
router.post('/api/deploy', async (ctx) => {
142180
const value = (await ctx.request.json()) as LinkerResponse
143-
144181
if (!value.address || !value.authChain || !value.chainId) {
145182
const errorMessage = `Invalid payload: ${Object.keys(value).join(' - ')}`
146183
logger.error(errorMessage)
147184
resolveLinkerPromise()
148185
return { status: 400, body: { message: errorMessage } }
149186
}
150187

151-
// Store the chainId so we can use it on the catalyst pointers.
152188
linkerResponse = value
153189

154190
try {
155191
await deployCallback(value)
156-
// If its a world we dont wait for the catalyst pointers.
157-
// Close the program.
158192
if (sceneInfo.isWorld) {
159193
resolveLinkerPromise()
160194
}
@@ -185,13 +219,15 @@ export interface SceneInfo {
185219
isPortableExperience: boolean
186220
isWorld: boolean
187221
world?: string
222+
deleteScenesFromWorldPayload?: string
188223
}
189224

190225
export async function getSceneInfo(
191226
components: Pick<CliComponents, 'config'>,
192227
scene: Scene,
193228
rootCID: string,
194-
skipValidations: boolean
229+
skipValidations: boolean,
230+
deleteScenesFromWorldPayload?: string
195231
) {
196232
const {
197233
scene: { parcels, base },
@@ -211,6 +247,54 @@ export async function getSceneInfo(
211247
skipValidations,
212248
isPortableExperience: !!isPortableExperience,
213249
isWorld: sceneHasWorldCfg(scene),
214-
world: scene.worldConfiguration?.name
250+
world: scene.worldConfiguration?.name,
251+
deleteScenesFromWorldPayload
215252
}
216253
}
254+
255+
export interface WorldScene {
256+
entityId: string
257+
parcels?: string[]
258+
entity?: { metadata?: { display?: { title?: string } } }
259+
}
260+
261+
export function getScenesOnOtherParcels(existingScenes: WorldScene[], deployingParcels: string[]): WorldScene[] {
262+
const parcelsSet = new Set(deployingParcels)
263+
return existingScenes.filter((scene) => {
264+
const sceneParcels = scene.parcels || []
265+
return sceneParcels.every((p) => !parcelsSet.has(p))
266+
})
267+
}
268+
269+
interface WorldScenesResponse {
270+
scenes: WorldScene[]
271+
total: number
272+
}
273+
274+
export async function fetchWorldScenes(
275+
logger: { info: (msg: string) => void },
276+
worldName: string,
277+
targetContent?: string
278+
): Promise<WorldScene[]> {
279+
const encodedName = encodeURIComponent(worldName)
280+
const url = `${targetContent}/world/${encodedName}/scenes`
281+
const response = await fetch(url)
282+
if (!response.ok) {
283+
const text = await response.text()
284+
logger.info(`[DEPLOY] fetching scenes from world - error: ${text}`)
285+
throw new Error(`Failed to fetch world scenes: ${response.status} ${response.statusText}`)
286+
}
287+
const data = (await response.json()) as WorldScenesResponse
288+
logger.info(`[DEPLOY] fetching scenes from world success: total=${data.total}, scenes=${data.scenes?.length}`)
289+
return data.scenes || []
290+
}
291+
292+
export function promptUser(question: string): Promise<boolean> {
293+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout })
294+
return new Promise((resolve) => {
295+
rl.question(question, (answer) => {
296+
rl.close()
297+
resolve(answer.toLowerCase() === 'y' || answer.toLowerCase() === 'yes')
298+
})
299+
})
300+
}

packages/@dcl/sdk-commands/src/linker-dapp/routes.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,4 +127,5 @@ export interface LinkerResponse {
127127
address: string
128128
authChain: AuthChain
129129
chainId?: ChainId
130+
deleteSignature?: string
130131
}

packages/@dcl/sdk-commands/src/logic/error.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ export type CliErrorName =
77
| 'DEPLOY_INVALID_PROJECT_TYPE'
88
| 'DEPLOY_INVALID_ARGUMENTS'
99
| 'DEPLOY_UPLOAD_FAILED'
10+
| 'DEPLOY_CANCELLED'
11+
| 'DEPLOY_DELETE_FAILED'
1012
// Export static errors
1113
| 'EXPORT_STATIC_BASE_URL_REQUIRED'
1214
| 'EXPORT_STATIC_INVALID_REALM_NAME'

0 commit comments

Comments
 (0)