Skip to content

Commit a98fa30

Browse files
committed
ship command refactor and prototype go command showing the qr code
1 parent 8bf0355 commit a98fa30

14 files changed

Lines changed: 418 additions & 22 deletions

File tree

src/api/index.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,25 @@ export async function getJob(jobId: string, projectId: string): Promise<Job> {
142142
return castJobDates(data)
143143
}
144144

145+
const MAX_RETRIES = 3
146+
const RETRY_DELAY_MS = 5000
147+
148+
// Returns the builds for a job, retrying if none are found
149+
// The retry is because of possible race condition when a job completes
150+
export async function getJobBuildsRetry(jobId: string, projectId: string, retries = MAX_RETRIES): Promise<Build[]> {
151+
let job: Job | null = null
152+
153+
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
154+
job = await getJob(jobId, projectId)
155+
if (job.builds && job.builds.length > 0) break
156+
if (attempt < MAX_RETRIES) await new Promise((res) => setTimeout(res, RETRY_DELAY_MS))
157+
}
158+
159+
if (!job?.builds || job.builds.length === 0) throw new Error('No builds found for this job after multiple attempts')
160+
161+
return job.builds
162+
}
163+
145164
// Returns a url with an OTP - when visited it authenticates the user
146165
export async function getSingleUseUrl(destination: string) {
147166
// Call the API to generate an OTP

src/commands/game/go.tsx

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import {BaseGameCommand} from '@cli/baseCommands/index.js'
2+
import {Go} from '@cli/components/Go.js'
3+
import {CommandGame} from '@cli/components/index.js'
4+
5+
import {render} from 'ink'
6+
7+
export default class GameGo extends BaseGameCommand<typeof GameGo> {
8+
static override args = {}
9+
static override description = 'Preview your game in the ShipThis Go app.'
10+
static override examples = ['<%= config.bin %> <%= command.id %>']
11+
static override flags = {
12+
...BaseGameCommand.flags,
13+
}
14+
15+
public async run(): Promise<void> {
16+
await this.ensureWeAreInAProjectDir()
17+
const gameId = this.getGameId()
18+
if (!gameId) {
19+
this.error('No game ID found')
20+
}
21+
22+
const handleComplete = () => process.exit(0)
23+
const handleError = (error: any) => {
24+
this.error(`Error generating go build: ${error}`)
25+
}
26+
27+
render(
28+
<CommandGame command={this}>
29+
<Go onComplete={handleComplete} onError={handleError} />
30+
</CommandGame>,
31+
)
32+
}
33+
}

src/commands/game/ship.tsx

Lines changed: 5 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import {Flags} from '@oclif/core'
22
import {render} from 'ink'
33

4-
import {downloadBuildById, getJob} from '@cli/api/index.js'
4+
import {downloadBuildById, getJobBuildsRetry} from '@cli/api/index.js'
55
import {BaseGameCommand} from '@cli/baseCommands/baseGameCommand.js'
66
import {CommandGame, Ship} from '@cli/components/index.js'
77
import {Job} from '@cli/types/api.js'
@@ -73,26 +73,13 @@ export default class GameShip extends BaseGameCommand<typeof GameShip> {
7373
this.error('No game ID found')
7474
}
7575

76-
const MAX_RETRIES = 3
77-
const RETRY_DELAY_MS = 5000
78-
79-
const handleComplete = async ([originalJob]: Job[]) => {
76+
const handleComplete = async ([job]: Job[]) => {
8077
if (!this.flags.download && !this.flags.downloadAPK) return process.exit(0)
81-
82-
let job: Job | null = null
83-
84-
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
85-
job = await getJob(originalJob.id, gameId)
86-
if (job.builds && job.builds.length > 0) break
87-
if (attempt < MAX_RETRIES) await new Promise((res) => setTimeout(res, RETRY_DELAY_MS))
88-
}
89-
90-
if (!job?.builds || job.builds.length === 0) this.error('No builds found for this job after multiple attempts')
91-
78+
// Use a retry mechanism to get the builds, as they may not be immediately available
79+
const builds = await getJobBuildsRetry(job.id, gameId)
9280
const {platform} = this.flags
9381
const type = platform === 'android' ? (this.flags.downloadAPK ? 'APK' : 'AAB') : 'IPA'
94-
95-
const build = job.builds.find((b) => b.buildType === type)
82+
const build = builds.find((b) => b.buildType === type)
9683
if (!build) this.error(`No build found for type ${type}`)
9784

9885
const filename = this.flags.download || this.flags.downloadAPK

src/components/Go.tsx

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import {useContext, useState} from 'react'
2+
3+
import {getShortUUID, useProjectJobListener, useStartShipOnMount} from '@cli/utils/index.js'
4+
import {getJobBuildsRetry} from '@cli/api/index.js'
5+
6+
import {CommandContext, GameContext, JobProgress, QRCodeTerminal} from './index.js'
7+
8+
interface Props {
9+
onComplete: () => void
10+
onError: (error: any) => void
11+
}
12+
13+
export const Go = ({onComplete, onError}: Props): JSX.Element| null => {
14+
const {command} = useContext(CommandContext)
15+
const {gameId} = useContext(GameContext)
16+
if (!command || !gameId) return null
17+
return <GoCommand command={command} gameId={gameId} onComplete={onComplete} onError={onError} />
18+
}
19+
20+
interface GoCommandProps extends Props {
21+
command: any
22+
gameId: string
23+
}
24+
25+
const GoCommand = ({command, gameId, onComplete, onError}: GoCommandProps): JSX.Element | null=> {
26+
const flags = {follow: false, platform: 'go'}
27+
28+
const {jobs: startedJobs} = useStartShipOnMount(command, flags, onError)
29+
30+
const handleJobCompleted = async (job: any) => {
31+
const [goBuild] = await getJobBuildsRetry(job.id, command.getGameId())
32+
setQRCodeData(getShortUUID(goBuild.id))
33+
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
34+
await sleep(500)
35+
onComplete()
36+
}
37+
38+
const handleJobFailed = (job: any) => {
39+
onError(new Error(`Go job failed: ${job.id}`))
40+
}
41+
42+
const {jobsById} = useProjectJobListener({
43+
projectId: gameId,
44+
onJobCompleted: handleJobCompleted,
45+
onJobFailed: handleJobFailed,
46+
})
47+
48+
const [qrCodeData, setQRCodeData] = useState<string | null>(null)
49+
50+
if (qrCodeData) {
51+
return <QRCodeTerminal data={qrCodeData} />
52+
}
53+
54+
if (startedJobs && startedJobs?.length > 0) {
55+
return <JobProgress job={startedJobs[0]} />
56+
}
57+
58+
return null
59+
}
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import {Dispatch, SetStateAction} from 'react'
2+
import {Text} from 'ink'
3+
import open from 'open'
4+
5+
import {getShortAuthRequiredUrl} from '@cli/api/index.js'
6+
import {Job} from '@cli/types/api.js'
7+
import {useSafeInput} from '@cli/utils/index.js'
8+
9+
interface KeyboardShortcutsProps {
10+
onToggleJobLogs: Dispatch<SetStateAction<boolean>>
11+
gameId?: string
12+
jobs?: Job[] | null
13+
}
14+
15+
export const KeyboardShortcuts = ({onToggleJobLogs, gameId, jobs}: KeyboardShortcutsProps) => {
16+
useSafeInput(async (input) => {
17+
if (!gameId) return
18+
const i = input.toLowerCase()
19+
switch (i) {
20+
case 'l': {
21+
onToggleJobLogs((prev) => !prev)
22+
break
23+
}
24+
25+
case 'b': {
26+
const dashUrl = jobs?.length === 1 ? `/games/${gameId}/job/${jobs[0].id}` : `/games/${gameId}`
27+
const url = await getShortAuthRequiredUrl(dashUrl)
28+
await open(url)
29+
break
30+
}
31+
}
32+
})
33+
34+
return (
35+
<>
36+
<Text>Press L to show and hide the job logs.</Text>
37+
<Text>Press B to open the ShipThis dashboard in your browser.</Text>
38+
</>
39+
)
40+
}

src/components/Ship/ShipResult.tsx

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import {Box} from 'ink'
2+
3+
import {WEB_URL} from '@cli/constants/config.js'
4+
import {Job, ShipGameFlags} from '@cli/types/index.js'
5+
import {getShortUUID} from '@cli/utils/index.js'
6+
7+
import {Markdown, JobLogTail} from '@cli/components/index.js'
8+
9+
interface ShipResultProps {
10+
gameId: string
11+
failedJobs: Job[] | null
12+
gameFlags: ShipGameFlags | null
13+
}
14+
15+
export const ShipResult = ({gameId, failedJobs, gameFlags}: ShipResultProps) => {
16+
return (
17+
failedJobs && (
18+
<>
19+
{failedJobs.length === 0 && (
20+
<Markdown
21+
filename="ship-success.md.ejs"
22+
templateVars={{
23+
gameBuildsUrl: `${WEB_URL}games/${getShortUUID(gameId)}/builds`,
24+
wasPublished: !gameFlags?.skipPublish,
25+
}}
26+
/>
27+
)}
28+
{failedJobs.length > 0 && (
29+
<>
30+
<Markdown
31+
filename="ship-failure.md.ejs"
32+
templateVars={{
33+
jobDashboardUrl: `${WEB_URL}games/${getShortUUID(gameId)}/job/${getShortUUID(failedJobs[0].id)}`,
34+
}}
35+
/>
36+
<Box marginTop={1}>
37+
{failedJobs.map((fj) => (
38+
<JobLogTail isWatching={false} jobId={fj.id} key={fj.id} length={10} projectId={fj.project.id} />
39+
))}
40+
</Box>
41+
</>
42+
)}
43+
</>
44+
)
45+
)
46+
}

src/components/Ship/index.tsx

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
import {Box, Text} from 'ink'
2+
import {useContext, useEffect, useState} from 'react'
3+
4+
import {CommandContext, GameContext, JobFollow, JobLogTail, JobProgress, JobStatusTable} from '@cli/components/index.js'
5+
import {Job, JobStatus, ShipGameFlags} from '@cli/types/index.js'
6+
import {useProjectJobListener, useStartShipOnMount} from '@cli/utils/hooks/index.js'
7+
8+
import {KeyboardShortcuts} from './KeyboardShortcuts.js'
9+
import {ShipResult} from './ShipResult.js'
10+
11+
interface Props {
12+
onComplete: (completedJobs: Job[]) => void
13+
onError: (error: any) => void
14+
}
15+
16+
export const Ship = ({onComplete, onError}: Props): JSX.Element | null => {
17+
const {command} = useContext(CommandContext)
18+
const {gameId} = useContext(GameContext)
19+
if (!command || !gameId) return null
20+
return <ShipCommand onComplete={onComplete} onError={onError} command={command} gameId={gameId} />
21+
}
22+
23+
interface ShipCommandProps extends Props {
24+
command: any
25+
gameId: string
26+
}
27+
28+
const ShipCommand = ({onComplete, onError, command, gameId}: ShipCommandProps) => {
29+
const [showLog, setShowLog] = useState<boolean>(false)
30+
const flags = command && (command.getFlags() as ShipGameFlags)
31+
const {jobs: startedJobs, shipLog} = useStartShipOnMount(command, flags, onError)
32+
const [failedJobs, setFailedJobs] = useState<Job[]>([])
33+
const [successJobs, setSuccessJobs] = useState<Job[]>([])
34+
const [isComplete, setIsComplete] = useState<boolean>(false)
35+
36+
const handleJobCompleted = (job: Job) => setSuccessJobs((prev) => [...prev, job])
37+
const handleJobFailed = (job: Job) => setFailedJobs((prev) => [...prev, job])
38+
39+
const {jobsById} = useProjectJobListener({
40+
projectId: gameId,
41+
onJobCompleted: handleJobCompleted,
42+
onJobFailed: handleJobFailed,
43+
})
44+
45+
useEffect(() => {
46+
// Detect when all jobs done and trigger onComplete or onError
47+
const totalCompleted = successJobs.length + failedJobs.length
48+
if (startedJobs && totalCompleted === startedJobs.length) {
49+
setIsComplete(true)
50+
setTimeout(() => {
51+
const didFail = failedJobs.length > 0
52+
if (didFail) {
53+
onError(new Error('One or more jobsById failed.'))
54+
} else {
55+
onComplete(successJobs)
56+
}
57+
}, 500)
58+
}
59+
}, [successJobs, failedJobs, startedJobs])
60+
61+
// Use startedJobs just for the ids - the "live" objects come from jobsById
62+
const inProgressJobs = startedJobs
63+
?.map((startedJob) => jobsById[startedJob.id])
64+
.filter((job) => job !== undefined)
65+
.filter((job) => job && [JobStatus.PENDING, JobStatus.PROCESSING].includes(job.status))
66+
67+
if (flags?.follow) {
68+
if (startedJobs && startedJobs.length > 0) {
69+
return <JobFollow jobId={startedJobs[0].id} projectId={gameId} />
70+
}
71+
return <></>
72+
}
73+
74+
return (
75+
<Box flexDirection="column">
76+
{startedJobs === null && <Text>{shipLog}</Text>}
77+
{inProgressJobs &&
78+
inProgressJobs.map((job) => (
79+
<Box flexDirection="column" key={job.id} marginBottom={1}>
80+
<JobStatusTable isWatching={true} jobId={job.id} projectId={job.project.id} />
81+
<Box flexDirection="column">
82+
<JobProgress job={job} />
83+
</Box>
84+
{showLog && (
85+
<Box marginTop={1}>
86+
<JobLogTail isWatching={true} jobId={job.id} length={10} projectId={job.project.id} />
87+
</Box>
88+
)}
89+
</Box>
90+
))}
91+
{jobsById && !isComplete && (
92+
<>
93+
<KeyboardShortcuts gameId={gameId} jobs={inProgressJobs} onToggleJobLogs={setShowLog} />
94+
<Text bold>Please wait while ShipThis builds your game...</Text>
95+
</>
96+
)}
97+
{isComplete && <ShipResult gameId={gameId} failedJobs={failedJobs} gameFlags={flags} />}
98+
</Box>
99+
)
100+
}

src/components/android/ConnectGoogle/GoogleAuthQRCode.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,5 +29,5 @@ export const GoogleAuthQRCode = ({gameId, helpPage}: GoogleAuthQRCodeProps) => {
2929
handleLoad()
3030
}, [])
3131

32-
return <>{url && <QRCodeTerminal url={url} />}</>
32+
return <>{url && <QRCodeTerminal data={url} />}</>
3333
}

src/components/common/QRCodeTerminal.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,11 @@ import qrcode from 'qrcode'
33
import {useEffect, useState} from 'react'
44

55
// A QR code that can be displayed in the terminal
6-
export const QRCodeTerminal = ({url}: {url: string}) => {
6+
export const QRCodeTerminal = ({data}: {data: string}) => {
77
const [code, setCode] = useState<null | string>(null)
88

99
const handleLoad = async () => {
10-
const codeString = await qrcode.toString(url, {errorCorrectionLevel: 'L', small: true, type: 'terminal'})
10+
const codeString = await qrcode.toString(data, {errorCorrectionLevel: 'L', small: true, type: 'terminal'})
1111
setCode(codeString)
1212
}
1313

src/components/index.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ export * from './JobLogTail.js'
55
export * from './JobStatusTable.js'
66
export * from './ProjectCredentialsTable.js'
77

8-
export * from './Ship.js'
8+
export * from './Ship/index.js'
99
export * from './UserCredentialsTable.js'
1010
export * from './android/index.js'
1111
export * from './apple/index.js'

0 commit comments

Comments
 (0)