Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions src/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,25 @@ export async function getJob(jobId: string, projectId: string): Promise<Job> {
return castJobDates(data)
}

const MAX_RETRIES = 3
const RETRY_DELAY_MS = 5000

// Returns the builds for a job, retrying if none are found
// The retry is because of possible race condition when a job completes
export async function getJobBuildsRetry(jobId: string, projectId: string, retries = MAX_RETRIES): Promise<Build[]> {
let job: Job | null = null

for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
job = await getJob(jobId, projectId)
if (job.builds && job.builds.length > 0) break
if (attempt < MAX_RETRIES) await new Promise((res) => setTimeout(res, RETRY_DELAY_MS))
}

if (!job?.builds || job.builds.length === 0) throw new Error('No builds found for this job after multiple attempts')

return job.builds
}

// Returns a url with an OTP - when visited it authenticates the user
export async function getSingleUseUrl(destination: string) {
// Call the API to generate an OTP
Expand Down
33 changes: 33 additions & 0 deletions src/commands/game/go.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import {BaseGameCommand} from '@cli/baseCommands/index.js'
import {Go} from '@cli/components/Go.js'
import {CommandGame} from '@cli/components/index.js'

import {render} from 'ink'

export default class GameGo extends BaseGameCommand<typeof GameGo> {
static override args = {}
static override description = 'Preview your game in the ShipThis Go app.'
static override examples = ['<%= config.bin %> <%= command.id %>']
static override flags = {
...BaseGameCommand.flags,
}

public async run(): Promise<void> {
await this.ensureWeAreInAProjectDir()
const gameId = this.getGameId()
if (!gameId) {
this.error('No game ID found')
}

const handleComplete = () => process.exit(0)
const handleError = (error: any) => {
this.error(`Error generating go build: ${error}`)
}

render(
<CommandGame command={this}>
<Go onComplete={handleComplete} onError={handleError} />
</CommandGame>,
)
}
}
27 changes: 7 additions & 20 deletions src/commands/game/ship.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import {Flags} from '@oclif/core'
import {render} from 'ink'

import {downloadBuildById, getJob} from '@cli/api/index.js'
import {downloadBuildById, getJobBuildsRetry} from '@cli/api/index.js'
import {BaseGameCommand} from '@cli/baseCommands/baseGameCommand.js'
import {CommandGame, Ship} from '@cli/components/index.js'
import {Job} from '@cli/types/api.js'
Expand Down Expand Up @@ -41,8 +41,8 @@ export default class GameShip extends BaseGameCommand<typeof GameShip> {
required: false,
}),
platform: Flags.string({
description: 'The platform to ship the game to. This can be "android" or "ios"',
options: ['android', 'ios'],
description: 'The platform to ship the game to. This can be "android", "ios", or "go"',
options: ['android', 'ios', 'go'],
required: false,
}),
skipPublish: Flags.boolean({
Expand Down Expand Up @@ -73,26 +73,13 @@ export default class GameShip extends BaseGameCommand<typeof GameShip> {
this.error('No game ID found')
}

const MAX_RETRIES = 3
const RETRY_DELAY_MS = 5000

const handleComplete = async ([originalJob]: Job[]) => {
const handleComplete = async ([job]: Job[]) => {
if (!this.flags.download && !this.flags.downloadAPK) return process.exit(0)

let job: Job | null = null

for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
job = await getJob(originalJob.id, gameId)
if (job.builds && job.builds.length > 0) break
if (attempt < MAX_RETRIES) await new Promise((res) => setTimeout(res, RETRY_DELAY_MS))
}

if (!job?.builds || job.builds.length === 0) this.error('No builds found for this job after multiple attempts')

// Use a retry mechanism to get the builds, as they may not be immediately available
const builds = await getJobBuildsRetry(job.id, gameId)
const {platform} = this.flags
const type = platform === 'android' ? (this.flags.downloadAPK ? 'APK' : 'AAB') : 'IPA'

const build = job.builds.find((b) => b.buildType === type)
const build = builds.find((b) => b.buildType === type)
if (!build) this.error(`No build found for type ${type}`)

const filename = this.flags.download || this.flags.downloadAPK
Expand Down
109 changes: 109 additions & 0 deletions src/components/Go.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import {useContext, useState} from 'react'
import {Box, Text} from 'ink'

import {TruncatedText} from './common/TruncatedText.js'
import {
getRuntimeLogLevelColor,
getShortTime,
getShortUUID,
useGoRuntimeLogListener,
useProjectJobListener,
useStartShipOnMount,
} from '@cli/utils/index.js'
import {getJobBuildsRetry} from '@cli/api/index.js'

import {CommandContext, GameContext, JobProgress, QRCodeTerminal, Title} from './index.js'
import {Job, Platform} from '@cli/types/api.js'

interface Props {
onComplete: () => void
onError: (error: any) => void
}

export const Go = ({onComplete, onError}: Props): JSX.Element | null => {
const {command} = useContext(CommandContext)
const {gameId} = useContext(GameContext)
if (!command || !gameId) return null
return <GoCommand command={command} gameId={gameId} onComplete={onComplete} onError={onError} />
}

interface GoCommandProps extends Props {
command: any
gameId: string
}

const LogListener = ({projectId, buildId}: {projectId: string; buildId: string}) => {
const {messages} = useGoRuntimeLogListener({projectId, buildId})

return (
<>
<Box flexDirection="column">
{messages.map((log, i) => {
const messageColor = getRuntimeLogLevelColor(log.level)
return (
<Box flexDirection="row" height={1} key={i} overflow="hidden">
<Box>
<Text>{getShortTime(log.sentAt)}</Text>
</Box>
<Box height={1} marginLeft={1} marginRight={2} overflow="hidden">
<TruncatedText color={messageColor}>{log.message}</TruncatedText>
</Box>
</Box>
)
})}
</Box>
</>
)
}

const GoCommand = ({command, gameId, onComplete, onError}: GoCommandProps): JSX.Element | null => {
const flags = {follow: false, platform: 'go'}

const [buildId, setBuildId] = useState<string | null>(null)
const [qrCodeData, setQRCodeData] = useState<string | null>(null)

const {jobs: startedJobs} = useStartShipOnMount(command, flags, onError)

const handleJobCompleted = async (job: Job) => {
if (job.type != Platform.GO) return
const [goBuild] = await getJobBuildsRetry(job.id, command.getGameId())
setBuildId(goBuild.id)
setQRCodeData(getShortUUID(goBuild.id))
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
await sleep(500)
//onComplete()
}

const handleJobFailed = (job: any) => {
if (job.type != Platform.GO) return
onError(new Error(`Go job failed: ${job.id}`))
}

const {jobsById} = useProjectJobListener({
projectId: gameId,
onJobCompleted: handleJobCompleted,
onJobFailed: handleJobFailed,
})

if (qrCodeData && buildId) {
return (
<Box flexDirection="column">
<Title>Go Build QR Code</Title>
<QRCodeTerminal data={qrCodeData} />
<Text>{`Go build ID: ${getShortUUID(buildId)}`}</Text>
<LogListener projectId={gameId} buildId={buildId} />
</Box>
)
}

if (startedJobs && startedJobs?.length > 0) {
return (
<Box flexDirection="column">
<Text>Generating Go build, please wait...</Text>
<JobProgress job={startedJobs[0]} />
</Box>
)
}

return null
}
40 changes: 40 additions & 0 deletions src/components/Ship/KeyboardShortcuts.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import {Dispatch, SetStateAction} from 'react'
import {Text} from 'ink'
import open from 'open'

import {getShortAuthRequiredUrl} from '@cli/api/index.js'
import {Job} from '@cli/types/api.js'
import {useSafeInput} from '@cli/utils/index.js'

interface KeyboardShortcutsProps {
onToggleJobLogs: Dispatch<SetStateAction<boolean>>
gameId?: string
jobs?: Job[] | null
}

export const KeyboardShortcuts = ({onToggleJobLogs, gameId, jobs}: KeyboardShortcutsProps) => {
useSafeInput(async (input) => {
if (!gameId) return
const i = input.toLowerCase()
switch (i) {
case 'l': {
onToggleJobLogs((prev) => !prev)
break
}

case 'b': {
const dashUrl = jobs?.length === 1 ? `/games/${gameId}/job/${jobs[0].id}` : `/games/${gameId}`
const url = await getShortAuthRequiredUrl(dashUrl)
await open(url)
break
}
}
})

return (
<>
<Text>Press L to show and hide the job logs.</Text>
<Text>Press B to open the ShipThis dashboard in your browser.</Text>
</>
)
}
47 changes: 47 additions & 0 deletions src/components/Ship/ShipResult.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import {Box} from 'ink'

import {WEB_URL} from '@cli/constants/config.js'
import {Job, ShipGameFlags} from '@cli/types/index.js'
import {getShortUUID} from '@cli/utils/index.js'

import {Markdown, JobLogTail} from '@cli/components/index.js'

interface ShipResultProps {
gameId: string
failedJobs: Job[] | null
gameFlags: ShipGameFlags | null
}

export const ShipResult = ({gameId, failedJobs, gameFlags}: ShipResultProps) => {
return (
failedJobs && (
<>
{failedJobs.length === 0 && (
<Markdown
filename="ship-success.md.ejs"
templateVars={{
gameBuildsUrl: `${WEB_URL}games/${getShortUUID(gameId)}/builds`,
wasPublished: !gameFlags?.skipPublish,
usedDemoCredentials: !!gameFlags?.useDemoCredentials,
}}
/>
)}
{failedJobs.length > 0 && (
<>
<Markdown
filename="ship-failure.md.ejs"
templateVars={{
jobDashboardUrl: `${WEB_URL}games/${getShortUUID(gameId)}/job/${getShortUUID(failedJobs[0].id)}`,
}}
/>
<Box marginTop={1}>
{failedJobs.map((fj) => (
<JobLogTail isWatching={false} jobId={fj.id} key={fj.id} length={10} projectId={fj.project.id} />
))}
</Box>
</>
)}
</>
)
)
}
Loading