diff --git a/src/api/index.ts b/src/api/index.ts index 3a90da2..3002dde 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -142,6 +142,25 @@ export async function getJob(jobId: string, projectId: string): Promise { 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 { + 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 diff --git a/src/commands/game/go.tsx b/src/commands/game/go.tsx new file mode 100644 index 0000000..e30cdce --- /dev/null +++ b/src/commands/game/go.tsx @@ -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 { + 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 { + 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( + + + , + ) + } +} diff --git a/src/commands/game/ship.tsx b/src/commands/game/ship.tsx index 26b7166..14d1bf9 100644 --- a/src/commands/game/ship.tsx +++ b/src/commands/game/ship.tsx @@ -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' @@ -41,8 +41,8 @@ export default class GameShip extends BaseGameCommand { 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({ @@ -73,26 +73,13 @@ export default class GameShip extends BaseGameCommand { 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 diff --git a/src/components/Go.tsx b/src/components/Go.tsx new file mode 100644 index 0000000..bcacd6b --- /dev/null +++ b/src/components/Go.tsx @@ -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 +} + +interface GoCommandProps extends Props { + command: any + gameId: string +} + +const LogListener = ({projectId, buildId}: {projectId: string; buildId: string}) => { + const {messages} = useGoRuntimeLogListener({projectId, buildId}) + + return ( + <> + + {messages.map((log, i) => { + const messageColor = getRuntimeLogLevelColor(log.level) + return ( + + + {getShortTime(log.sentAt)} + + + {log.message} + + + ) + })} + + + ) +} + +const GoCommand = ({command, gameId, onComplete, onError}: GoCommandProps): JSX.Element | null => { + const flags = {follow: false, platform: 'go'} + + const [buildId, setBuildId] = useState(null) + const [qrCodeData, setQRCodeData] = useState(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 ( + + Go Build QR Code + + {`Go build ID: ${getShortUUID(buildId)}`} + + + ) + } + + if (startedJobs && startedJobs?.length > 0) { + return ( + + Generating Go build, please wait... + + + ) + } + + return null +} diff --git a/src/components/Ship/KeyboardShortcuts.tsx b/src/components/Ship/KeyboardShortcuts.tsx new file mode 100644 index 0000000..e965db5 --- /dev/null +++ b/src/components/Ship/KeyboardShortcuts.tsx @@ -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> + 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 ( + <> + Press L to show and hide the job logs. + Press B to open the ShipThis dashboard in your browser. + + ) +} diff --git a/src/components/Ship/ShipResult.tsx b/src/components/Ship/ShipResult.tsx new file mode 100644 index 0000000..596e8ae --- /dev/null +++ b/src/components/Ship/ShipResult.tsx @@ -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 && ( + + )} + {failedJobs.length > 0 && ( + <> + + + {failedJobs.map((fj) => ( + + ))} + + + )} + + ) + ) +} diff --git a/src/components/Ship/index.tsx b/src/components/Ship/index.tsx new file mode 100644 index 0000000..93def9c --- /dev/null +++ b/src/components/Ship/index.tsx @@ -0,0 +1,100 @@ +import {Box, Text} from 'ink' +import {useContext, useEffect, useState} from 'react' + +import {CommandContext, GameContext, JobFollow, JobLogTail, JobProgress, JobStatusTable} from '@cli/components/index.js' +import {Job, JobStatus, ShipGameFlags} from '@cli/types/index.js' +import {useProjectJobListener, useStartShipOnMount} from '@cli/utils/hooks/index.js' + +import {KeyboardShortcuts} from './KeyboardShortcuts.js' +import {ShipResult} from './ShipResult.js' + +interface Props { + onComplete: (completedJobs: Job[]) => void + onError: (error: any) => void +} + +export const Ship = ({onComplete, onError}: Props): JSX.Element | null => { + const {command} = useContext(CommandContext) + const {gameId} = useContext(GameContext) + if (!command || !gameId) return null + return +} + +interface ShipCommandProps extends Props { + command: any + gameId: string +} + +const ShipCommand = ({onComplete, onError, command, gameId}: ShipCommandProps) => { + const [showLog, setShowLog] = useState(false) + const flags = command && (command.getFlags() as ShipGameFlags) + const {jobs: startedJobs, shipLog} = useStartShipOnMount(command, flags, onError) + const [failedJobs, setFailedJobs] = useState([]) + const [successJobs, setSuccessJobs] = useState([]) + const [isComplete, setIsComplete] = useState(false) + + const handleJobCompleted = (job: Job) => setSuccessJobs((prev) => [...prev, job]) + const handleJobFailed = (job: Job) => setFailedJobs((prev) => [...prev, job]) + + const {jobsById} = useProjectJobListener({ + projectId: gameId, + onJobCompleted: handleJobCompleted, + onJobFailed: handleJobFailed, + }) + + useEffect(() => { + // Detect when all jobs done and trigger onComplete or onError + const totalCompleted = successJobs.length + failedJobs.length + if (startedJobs && totalCompleted === startedJobs.length) { + setIsComplete(true) + setTimeout(() => { + const didFail = failedJobs.length > 0 + if (didFail) { + onError(new Error('One or more jobsById failed.')) + } else { + onComplete(successJobs) + } + }, 500) + } + }, [successJobs, failedJobs, startedJobs]) + + // Use startedJobs just for the ids - the "live" objects come from jobsById + const inProgressJobs = startedJobs + ?.map((startedJob) => jobsById[startedJob.id]) + .filter((job) => job !== undefined) + .filter((job) => job && [JobStatus.PENDING, JobStatus.PROCESSING].includes(job.status)) + + if (flags?.follow) { + if (startedJobs && startedJobs.length > 0) { + return + } + return <> + } + + return ( + + {startedJobs === null && {shipLog}} + {inProgressJobs && + inProgressJobs.map((job) => ( + + + + + + {showLog && ( + + + + )} + + ))} + {jobsById && !isComplete && ( + <> + + Please wait while ShipThis builds your game... + + )} + {isComplete && } + + ) +} diff --git a/src/components/android/ConnectGoogle/GoogleAuthQRCode.tsx b/src/components/android/ConnectGoogle/GoogleAuthQRCode.tsx index 908c353..1a805b1 100644 --- a/src/components/android/ConnectGoogle/GoogleAuthQRCode.tsx +++ b/src/components/android/ConnectGoogle/GoogleAuthQRCode.tsx @@ -29,5 +29,5 @@ export const GoogleAuthQRCode = ({gameId, helpPage}: GoogleAuthQRCodeProps) => { handleLoad() }, []) - return <>{url && } + return <>{url && } } diff --git a/src/components/common/QRCodeTerminal.tsx b/src/components/common/QRCodeTerminal.tsx index e14d837..f043dc1 100644 --- a/src/components/common/QRCodeTerminal.tsx +++ b/src/components/common/QRCodeTerminal.tsx @@ -3,11 +3,11 @@ import qrcode from 'qrcode' import {useEffect, useState} from 'react' // A QR code that can be displayed in the terminal -export const QRCodeTerminal = ({url}: {url: string}) => { +export const QRCodeTerminal = ({data}: {data: string}) => { const [code, setCode] = useState(null) const handleLoad = async () => { - const codeString = await qrcode.toString(url, {errorCorrectionLevel: 'L', small: true, type: 'terminal'}) + const codeString = await qrcode.toString(data, {errorCorrectionLevel: 'L', small: true, type: 'terminal'}) setCode(codeString) } diff --git a/src/components/index.tsx b/src/components/index.tsx index c0dffcb..6412a0b 100644 --- a/src/components/index.tsx +++ b/src/components/index.tsx @@ -5,7 +5,7 @@ export * from './JobLogTail.js' export * from './JobStatusTable.js' export * from './ProjectCredentialsTable.js' -export * from './Ship.js' +export * from './Ship/index.js' export * from './UserCredentialsTable.js' export * from './android/index.js' export * from './apple/index.js' diff --git a/src/types/api.ts b/src/types/api.ts index b888269..43e7aa1 100644 --- a/src/types/api.ts +++ b/src/types/api.ts @@ -28,6 +28,7 @@ export type SelfWithJWT = { export enum Platform { ANDROID = 'ANDROID', IOS = 'IOS', + GO = 'GO', } export enum GameEngine { @@ -178,6 +179,7 @@ export enum BuildType { AAB = 'AAB', APK = 'APK', IPA = 'IPA', + GO = 'GO', } export interface Build { @@ -269,3 +271,20 @@ export interface TermsResponse { changes: AgreementVersion[] current: AgreementVersion[] } + +export enum RuntimeLogLevel { + VERBOSE = 'VERBOSE', + DEBUG = 'DEBUG', + INFO = 'INFO', + WARNING = 'WARNING', + ERROR = 'ERROR', +} + +export interface RuntimeLogEntry { + buildId: string + level: RuntimeLogLevel + message: string + details?: any + sentAt: DateTime + sequence: number // will be min of 1 +} \ No newline at end of file diff --git a/src/types/index.ts b/src/types/index.ts index 4b25ea4..90812e6 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -24,7 +24,7 @@ export type ShipGameFlags = { download?: string downloadAPK?: string follow?: boolean - platform?: 'android' | 'ios' + platform?: 'android' | 'ios' | 'go' skipPublish?: boolean verbose?: boolean useDemoCredentials?: boolean diff --git a/src/utils/hooks/index.ts b/src/utils/hooks/index.ts index c33f8b5..a32bcc3 100644 --- a/src/utils/hooks/index.ts +++ b/src/utils/hooks/index.ts @@ -1,7 +1,10 @@ export * from './useAndroidServiceAccount.js' export * from './useGoogleStatusWatching.js' +export * from './useGoRuntimeLogListener.js' export * from './useJobLogTail.js' export * from './useJobWatching.js' +export * from './useProjectJobListener.js' export * from './useResponsive.js' export * from './useSafeInput.js' +export * from './useStartShipOnMount.js' export * from './useWebSocket.js' \ No newline at end of file diff --git a/src/utils/hooks/useGoRuntimeLogListener.ts b/src/utils/hooks/useGoRuntimeLogListener.ts new file mode 100644 index 0000000..693f084 --- /dev/null +++ b/src/utils/hooks/useGoRuntimeLogListener.ts @@ -0,0 +1,69 @@ +import {useEffect, useRef, useState} from 'react' +import {DateTime} from 'luxon' + +import {WebSocketListener, useWebSocket} from './useWebSocket.js' +import {RuntimeLogEntry} from '@cli/types/api.js' + +interface Props { + projectId: string + buildId: string + tailLength?: number + maxMessages?: number + flushIntervalMs?: number +} + +interface Response { + messages: RuntimeLogEntry[] + tail: RuntimeLogEntry[] +} + +// Saves incoming runtime log entries to a ref queue, and flushes them to state +// at a regular interval. This reduces the number of re-renders. +export function useGoRuntimeLogListener({ + projectId, + buildId, + tailLength = 10, + maxMessages = 500, + flushIntervalMs = 100, // 10fps +}: Props): Response { + const [messages, setMessages] = useState([]) + const [tail, setTail] = useState([]) + + const queueRef = useRef([]) + + const listener: WebSocketListener = { + async eventHandler(_: string, rawLog: any) { + queueRef.current.push({ + ...rawLog, + sentAt: DateTime.fromISO(rawLog.sentAt), + }) + }, + + getPattern: () => [`project.${projectId}:build.${buildId}:runtime-log`], + } + + useWebSocket([listener]) + + useEffect(() => { + const id = setInterval(() => { + const queued = queueRef.current + if (queued.length === 0) return + + queueRef.current = [] + + setMessages((prev) => { + const next = [...prev, ...queued] + return next.length > maxMessages ? next.slice(-maxMessages) : next + }) + + setTail((prev) => { + const next = [...prev, ...queued] + return next.length > tailLength ? next.slice(-tailLength) : next + }) + }, flushIntervalMs) + + return () => clearInterval(id) + }, [tailLength, maxMessages, flushIntervalMs]) + + return {messages, tail} +} diff --git a/src/utils/hooks/useProjectJobListener.ts b/src/utils/hooks/useProjectJobListener.ts new file mode 100644 index 0000000..d94dc7b --- /dev/null +++ b/src/utils/hooks/useProjectJobListener.ts @@ -0,0 +1,69 @@ +import {useRef, useState} from 'react' + +import {Job, JobStatus} from '@cli/types' +import {castJobDates} from '@cli/utils/dates.js' +import {WebSocketListener, useWebSocket} from './useWebSocket.js' + +export interface ProjectJobListenerProps { + projectId: string + onJobCreated?: (j: Job) => void + onJobUpdated?: (j: Job) => void + onJobCompleted?: (j: Job) => void + onJobFailed?: (j: Job) => void +} + +type JobsById = Record +type JobStatusById = Record + +export interface ProjectJobListenerResult { + jobsById: JobsById +} + +// Listens for all job updates related to a specific project. +// Does not need a jobId +export function useProjectJobListener({ + projectId, + onJobCreated, + onJobUpdated, + onJobCompleted, + onJobFailed, +}: ProjectJobListenerProps): ProjectJobListenerResult { + const [jobsById, setJobsById] = useState({}) + const prevJobStatuses = useRef({}) + + const handleJobUpdate = (job: Job) => { + const completed = new Set([JobStatus.COMPLETED, JobStatus.FAILED]) + const wasRunning = !completed.has(prevJobStatuses.current[job.id]) + if (completed.has(job.status) && wasRunning) { + if (job.status === JobStatus.FAILED) { + onJobFailed && onJobFailed(job) + } else { + onJobCompleted && onJobCompleted(job) + } + } + prevJobStatuses.current[job.id] = job.status + } + + const jobCreatedListener: WebSocketListener = { + async eventHandler(pattern: string, rawJob: any) { + const job = castJobDates(rawJob) + setJobsById((prev) => ({...prev, [job.id]: job})) + if (onJobCreated) onJobCreated(job) + }, + getPattern: () => [`project.${projectId}:job:created`], + } + + const jobUpdatedListener: WebSocketListener = { + async eventHandler(pattern: string, rawJob: any) { + const job = castJobDates(rawJob) + setJobsById((prev) => ({...prev, [job.id]: job})) + if (onJobUpdated) onJobUpdated(job) + handleJobUpdate(job) + }, + getPattern: () => [`project.${projectId}:job:updated`], + } + + useWebSocket([jobCreatedListener, jobUpdatedListener]) + + return {jobsById} +} diff --git a/src/utils/hooks/useStartShipOnMount.ts b/src/utils/hooks/useStartShipOnMount.ts new file mode 100644 index 0000000..6630980 --- /dev/null +++ b/src/utils/hooks/useStartShipOnMount.ts @@ -0,0 +1,27 @@ +import {useEffect, useState} from 'react' +import {Job} from '@cli/types/index.js' +import {useShip} from '@cli/utils/index.js' + +export const useStartShipOnMount = (command: any, flags: any, onError: (e: any) => void) => { + const shipMutation = useShip() + + const [shipLog, setShipLog] = useState('') + const [jobs, setJobs] = useState(null) + + // Start the command on mount + const handleStartOnMount = async () => { + if (!command) throw new Error('No command in context') + const logFn = flags?.follow ? console.log : setShipLog + const startedJobs = await shipMutation.mutateAsync({command, log: logFn, shipFlags: flags}) + setJobs(startedJobs) + } + + useEffect(() => { + handleStartOnMount().catch(onError) + }, []) + + return { + jobs, + shipLog, + } +} \ No newline at end of file diff --git a/src/utils/hooks/useWebSocket.ts b/src/utils/hooks/useWebSocket.ts index 9dcedf6..0c4a24f 100644 --- a/src/utils/hooks/useWebSocket.ts +++ b/src/utils/hooks/useWebSocket.ts @@ -1,4 +1,4 @@ -import {useEffect} from 'react' +import {useEffect, useRef} from 'react' import {io} from 'socket.io-client' import {getAuthToken} from '@cli/api/index.js' @@ -13,6 +13,7 @@ export interface WebSocketListener { export function useWebSocket(listeners: WebSocketListener[] = []) { const log = false ? console.debug : () => {} + const socketRef = useRef | null>(null) useEffect(() => { if (listeners.length === 0) { @@ -20,11 +21,13 @@ export function useWebSocket(listeners: WebSocketListener[] = []) { return } - const token = getAuthToken() - const socket = io(WS_URL, { - auth: {token}, - forceNew: true, - }) + if (!socketRef.current) { + const token = getAuthToken() + socketRef.current = io(WS_URL, { + auth: {token}, + }) + } + const socket = socketRef.current socket.on('connect', () => log('Connected to WebSocket')) @@ -43,10 +46,12 @@ export function useWebSocket(listeners: WebSocketListener[] = []) { bindSocket(pattern) } + }, []) + useEffect(() => { return () => { - log('Disconnecting from WebSocket') - socket.disconnect() + socketRef.current?.disconnect() + socketRef.current = null } }, []) } diff --git a/src/utils/index.ts b/src/utils/index.ts index 68c9dbb..5081672 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -6,7 +6,7 @@ import {fileURLToPath} from 'node:url' import readlineSync from 'readline-sync' -import {JobStage, JobStatus, LogLevel, Platform, ScalarDict} from '@cli/types' +import {JobStage, JobStatus, LogLevel, Platform, RuntimeLogLevel, ScalarDict} from '@cli/types' export * from './dates.js' export * from './dictionary.js' @@ -85,6 +85,18 @@ export function getJobStatusColor(status: JobStatus) { } } +// For the "Go" runtime logs +const RUNTIME_LOG_LEVEL_COLORS: Record = { + [RuntimeLogLevel.VERBOSE]: '#E8E8FF', + [RuntimeLogLevel.DEBUG]: '#E6F0FF', + [RuntimeLogLevel.INFO]: '#E6FFE6', + [RuntimeLogLevel.WARNING]: '#FFF6CC', + [RuntimeLogLevel.ERROR]: '#FFD6D6', +} + +export const getRuntimeLogLevelColor = (level: RuntimeLogLevel) => + RUNTIME_LOG_LEVEL_COLORS[level] + export async function getFileHash(filename: string): Promise { return new Promise((resolve, reject) => { const hash = crypto.createHash('sha256') @@ -146,6 +158,10 @@ export function getPlatformName(platform: Platform): string { return 'Android' } + case Platform.GO: { + return 'Go' + } + default: { throw new Error(`Unknown platform: ${platform}`) } diff --git a/src/utils/query/useShip.ts b/src/utils/query/useShip.ts index 2d6db06..a4f812d 100644 --- a/src/utils/query/useShip.ts +++ b/src/utils/query/useShip.ts @@ -36,8 +36,10 @@ export async function ship({command, log = () => {}, shipFlags}: ShipOptions): P const hasConfiguredIos = Boolean(project.details?.iosBundleId) const hasConfiguredAndroid = Boolean(project.details?.androidPackageName) + const hasOnePlatformConfigured = hasConfiguredAndroid || hasConfiguredIos + const isGo = finalFlags?.platform === 'go' - if (!isUsingDemoCredentials && !hasConfiguredAndroid && !hasConfiguredIos) { + if (!isGo && !isUsingDemoCredentials && !hasOnePlatformConfigured) { throw new Error( 'No Android or iOS configuration found. Please run `shipthis game wizard android` or `shipthis game wizard ios` to configure your game.', ) diff --git a/test/commands/game/go.test.ts b/test/commands/game/go.test.ts new file mode 100644 index 0000000..aac4337 --- /dev/null +++ b/test/commands/game/go.test.ts @@ -0,0 +1,14 @@ +import {runCommand} from '@oclif/test' +import {expect} from 'chai' + +describe('game:go', () => { + it('runs game:go cmd', async () => { + const {stdout} = await runCommand('game:go') + expect(stdout).to.contain('hello world') + }) + + it('runs game:go --name oclif', async () => { + const {stdout} = await runCommand('game:go --name oclif') + expect(stdout).to.contain('hello oclif') + }) +})