Skip to content
Merged
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
14 changes: 9 additions & 5 deletions docs/game/create.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,11 @@ directory then you will need to use the `--force` flag to create a new game and
overwrite this file with the config for the new game.

ShipThis will detect the version of Godot you are using from your **project.godot**
file. This can be changed with the [`shipthis game details`](/docs/reference/game/details)
command.
file. To set a different version, pass the `--gameEngineVersion` flag, or change it later
with the [`shipthis game details`](/docs/reference/game/details) command.

The CLI checks the values you pass. The rules are the same as the ones the
[`shipthis game details`](/docs/reference/game/details) command applies.

## Example

Expand All @@ -28,16 +31,17 @@ command.

```help
USAGE
$ shipthis game create [-f] [-q] [-a <value>] [-b <value>] [-e <value>] [-v <value>] [--gcpProjectId <value>]
[-c <value>] [-i <value>] [-l <value>] [-n <value>] [-s <value>] [-d true|false]
$ shipthis game create [-f] [-q] [-a <value>] [-b <value>] [-e godot] [-v <value>] [--gcpProjectId <value>] [-c
<value>] [-i <value>] [-l <value>] [-n <value>] [-s <value>] [-d true|false]

FLAGS
-a, --androidPackageName=<value> Set the Android package name
-b, --buildNumber=<value> Set the build number
-c, --gcpServiceAccountId=<value> Set the GCP service account ID
-d, --useDemoCredentials=<option> Use demo credentials for this project
<options: true|false>
-e, --gameEngine=<value> Set the game engine
-e, --gameEngine=<option> Set the game engine
<options: godot>
-f, --force
-i, --iosBundleId=<value> Set the iOS bundle ID
-l, --liquidGlassIconPath=<value> Set the Liquid Glass icon path
Expand Down
26 changes: 24 additions & 2 deletions docs/game/details.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,27 @@ The following fields can only be changed if you have the `--force` flag set:
After changing these values, you will need to trigger a new build of your game with [`shipthis game ship`](/docs/reference/game/ship)
:::

## Validation

The CLI checks each value before it sends it to the server. A value the build server cannot
use is reported here, in a second, rather than in a failed build minutes later.

| Field | Rule |
| --- | --- |
| **name** | Not empty. 64 characters or less. |
| **gameEngine** | `godot` only. |
| **gameEngineVersion** | A supported Godot version, such as `4.2`. You can pin a patch, such as `4.2.1`. See [Godot versioning](/docs/guides/godot-versioning). |
| **semanticVersion** | Three numbers, such as `1.2.3`. The App Store rejects a suffix such as `-beta`. See [versioning](/docs/guides/versioning). |
| **buildNumber** | A whole number from 1 to 2100000000. The top value is the largest Google Play accepts as a versionCode. |
| **androidPackageName** | Two or more segments, such as `com.mystudio.mygame`. Each segment starts with a letter and holds letters, numbers, and underscores only. See the [Android application ID rules](https://developer.android.com/build/configure-app-module#set-application-id). Use a domain you own. Google Play rejects a name that starts with `com.example.` at upload. |
| **iosBundleId** | The usual reverse-DNS form, such as `com.mystudio.mygame`. Letters, numbers, and hyphens only. See [CFBundleIdentifier](https://developer.apple.com/documentation/bundleresources/information-property-list/cfbundleidentifier). |
| **liquidGlassIconPath** | A `.icon` folder that exists on your machine. See the [Liquid Glass guide](/docs/guides/liquid-glass). |

The same checks run on [`shipthis game create`](/docs/reference/game/create), and on the
`--gameEngineVersion` flag of [`shipthis game ship`](/docs/reference/game/ship).

The **gcpProjectId** and **gcpServiceAccountId** fields are not checked here.

## Example

[![asciicast](https://asciinema.org/a/5eIVmJYQ6MxDAlFVoVKXhGkYr.svg)](https://asciinema.org/a/5eIVmJYQ6MxDAlFVoVKXhGkYr)
Expand All @@ -29,7 +50,7 @@ After changing these values, you will need to trigger a new build of your game w

```help
USAGE
$ shipthis game details [-g <value>] [-f] [-a <value>] [-b <value>] [-e <value>] [-v <value>] [--gcpProjectId
$ shipthis game details [-g <value>] [-f] [-a <value>] [-b <value>] [-e godot] [-v <value>] [--gcpProjectId
<value>] [-c <value>] [-i <value>] [-l <value>] [-n <value>] [-s <value>] [-d true|false]

FLAGS
Expand All @@ -38,7 +59,8 @@ FLAGS
-c, --gcpServiceAccountId=<value> Set the GCP service account ID
-d, --useDemoCredentials=<option> Use demo credentials for this project
<options: true|false>
-e, --gameEngine=<value> Set the game engine
-e, --gameEngine=<option> Set the game engine
<options: godot>
-f, --force Force the command to run
-g, --gameId=<value> The ID of the game
-i, --iosBundleId=<value> Set the iOS bundle ID
Expand Down
3 changes: 3 additions & 0 deletions docs/game/ship.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,9 @@ You can specify a different Godot version to use only for the current job. This
shipthis game ship --platform android --follow --gameEngineVersion 4.5.1 --download game-4.5.1.aab
```

The CLI checks this version before it builds the zip, so a typo stops the command in a second.
See [Godot versioning](/docs/guides/godot-versioning) for the versions ShipThis supports.

## Help Output

```help
Expand Down
20 changes: 19 additions & 1 deletion src/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import * as fs from 'node:fs'

import axios from 'axios'

import {API_URL, WEB_URL} from '@cli/constants/index.js'
import {API_URL, SUPPORTED_GODOT_VERSIONS, WEB_URL} from '@cli/constants/index.js'
import {
AgreementVersion,
APIKey,
Expand All @@ -28,6 +28,7 @@ import {
} from '@cli/types'
import {castArrayObjectDates, castJobDates, castObjectDates} from '@cli/utils/dates.js'
import {toHandledError} from '@cli/utils/index.js'
import {isVersionList} from '@cli/utils/validation.js'

export * from './credentials/index.js'

Expand All @@ -47,6 +48,23 @@ export function getAuthedHeaders() {
}
}

// How long to wait for the Godot version list before using the built-in one.
const GODOT_VERSIONS_TIMEOUT_MS = 3000

/**
* The Godot versions the build server has templates for. The route is public, so this needs
* no token. Any failure - no network, a timeout, a body of another shape - answers with the
* built-in list, because a version check must never be the reason a command cannot run.
*/
export async function getSupportedGodotVersions(): Promise<string[]> {
try {
const {data} = await axios.get(`${API_URL}/godot/versions`, {timeout: GODOT_VERSIONS_TIMEOUT_MS})
return isVersionList(data) ? data : SUPPORTED_GODOT_VERSIONS
} catch {
return SUPPORTED_GODOT_VERSIONS
Comment thread
madebydavid marked this conversation as resolved.
}
}

export interface CreateProjectProps {
details: ProjectDetails
name: string
Expand Down
35 changes: 32 additions & 3 deletions src/commands/game/create.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import {Flags} from '@oclif/core'

import {createProject} from '@cli/api/index.js'
import {createProject, getSupportedGodotVersions} from '@cli/api/index.js'
import {BaseAuthenticatedCommand} from '@cli/baseCommands/index.js'
import {DEFAULT_PLATFORM_GLOBS, DetailsFlags} from '@cli/constants/index.js'
import {GameEngine, ProjectDetails} from '@cli/types'
import {getGodotProjectName, getGodotVersion, isCWDGodotGame} from '@cli/utils/godot.js'
import {getInput} from '@cli/utils/index.js'
import {DetailsValues, getInput, isSupportedGodotVersion, validateDetailsValues} from '@cli/utils/index.js'

export default class GameCreate extends BaseAuthenticatedCommand<typeof GameCreate> {
static override args = {}
Expand All @@ -25,6 +25,12 @@ export default class GameCreate extends BaseAuthenticatedCommand<typeof GameCrea

const {force, name: flagName, quiet, ...details} = flags

// create always needs the list: it checks the flag, and it warns about the version it
// reads from project.godot. One call answers both.
const godotVersions = await getSupportedGodotVersions()

this.validateOrError(details, godotVersions)

if (this.hasProjectConfig() && !force) {
throw new Error('This directory already has a ShipThis project. Use --force to overwrite.')
}
Expand All @@ -41,9 +47,25 @@ export default class GameCreate extends BaseAuthenticatedCommand<typeof GameCrea
}

const name = await getName()
this.validateOrError({name}, godotVersions)

const gameEngine = GameEngine.GODOT
const gameEngineVersion = getGodotVersion()

// A version the user typed is already checked above. A version we read from project.godot
// only warns - an older CLI can have a list that the build server has moved past, and a
// hard stop there would block a build the server can do.
const detectedVersion = getGodotVersion()
const gameEngineVersion = details.gameEngineVersion || detectedVersion

// --quiet promises no output except interactions and errors, and this is neither.
if (!quiet && !details.gameEngineVersion && !isSupportedGodotVersion(detectedVersion, godotVersions)) {
this.warn(
`Your project.godot targets Godot ${detectedVersion}, which is not in the list of versions ShipThis builds.\n` +
`If the build fails, pin a supported version:\n\n` +
` shipthis game details --gameEngineVersion ${godotVersions.at(-1)} --force\n\n` +
`See https://shipth.is/docs/guides/godot-versioning`,
)
}

const projectDetails: ProjectDetails = {
...details,
Expand All @@ -61,4 +83,11 @@ export default class GameCreate extends BaseAuthenticatedCommand<typeof GameCrea

if (!flags.quiet) await this.config.runCommand('game:status')
}

// Stops the command on the first bad value, with the docs link for that field.
private validateOrError(values: DetailsValues, godotVersions: string[]): void {
const error = validateDetailsValues(values, godotVersions)
if (!error) return
this.error(error.message, {exit: 1, ref: error.ref, suggestions: error.suggestions})
}
}
24 changes: 17 additions & 7 deletions src/commands/game/details.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import {Flags} from '@oclif/core'
import {render} from 'ink'

import {getSupportedGodotVersions} from '@cli/api/index.js'
import {BaseGameCommand} from '@cli/baseCommands/index.js'
import {Command, StatusTable} from '@cli/components/index.js'
import {DetailsFlags} from '@cli/constants/index.js'
import {DetailsFlags, SUPPORTED_GODOT_VERSIONS} from '@cli/constants/index.js'
import {GameEngine} from '@cli/types'
import {isValidSemVer} from '@cli/utils/index.js'
import {validateDetailsValues} from '@cli/utils/index.js'

export default class GameDetails extends BaseGameCommand<typeof GameDetails> {
static override args = {}
Expand Down Expand Up @@ -42,21 +43,30 @@ export default class GameDetails extends BaseGameCommand<typeof GameDetails> {
useDemoCredentials,
} = valueFlags

if (semanticVersion && !isValidSemVer(semanticVersion))
throw new Error(`Invalid semantic version: ${semanticVersion}`)
// Only ask the server which versions it builds when there is a version to check.
const godotVersions = gameEngineVersion ? await getSupportedGodotVersions() : SUPPORTED_GODOT_VERSIONS

// Values come before the --force gate, so a wrong value is named without --force.
// this.error() exits, so only this fault prints.
const validationError = validateDetailsValues(valueFlags, godotVersions)
if (validationError) {
this.error(validationError.message, {
exit: 1,
ref: validationError.ref,
suggestions: validationError.suggestions,
})
}

if ((gameEngine || gameEngineVersion || iosBundleId || androidPackageName) && !force)
throw new Error('Use --force to set the restricted fields')

if (gameEngine && gameEngine !== GameEngine.GODOT) throw new Error(`Game engine ${gameEngine} not supported`)

let game = await this.getGame()

const update = {
details: {
...game.details,
...(androidPackageName && {androidPackageName}),
...(buildNumber && {buildNumber}),
...(buildNumber !== undefined && {buildNumber}),
...(gameEngine && {gameEngine: gameEngine as GameEngine}),
...(gameEngineVersion && {gameEngineVersion}),
...(gcpProjectId !== undefined && {gcpProjectId}),
Expand Down
19 changes: 18 additions & 1 deletion src/commands/game/ship.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import {Flags} from '@oclif/core'
import {render} from 'ink'

import {downloadBuildById, getJob} from '@cli/api/index.js'
import {downloadBuildById, getJob, getSupportedGodotVersions} from '@cli/api/index.js'
import {BaseGameCommand} from '@cli/baseCommands/baseGameCommand.js'
import {CommandGame, Ship} from '@cli/components/index.js'
import {SUPPORTED_GODOT_VERSIONS} from '@cli/constants/index.js'
import {Job} from '@cli/types/api.js'
import {getErrorMessage} from '@cli/utils/errors.js'
import {validateDetailsValues} from '@cli/utils/validation.js'

export default class GameShip extends BaseGameCommand<typeof GameShip> {
static override args = {}
Expand Down Expand Up @@ -68,6 +70,8 @@ export default class GameShip extends BaseGameCommand<typeof GameShip> {
}),
gameEngineVersion: Flags.string({
description: 'Override the specified game engine version for this build',
// Trim, as DetailsFlags does - what run() checks is what the job reads.
parse: async (input: string) => input.trim(),
required: false,
}),
dryRun: Flags.boolean({
Expand All @@ -78,6 +82,19 @@ export default class GameShip extends BaseGameCommand<typeof GameShip> {
}

public async run(): Promise<void> {
// Checked before the zip and the upload, so a typo costs no wait. Without the flag there
// is nothing to check, and the command asks the server nothing.
const {gameEngineVersion} = this.flags
const godotVersions = gameEngineVersion ? await getSupportedGodotVersions() : SUPPORTED_GODOT_VERSIONS
const validationError = validateDetailsValues({gameEngineVersion}, godotVersions)
if (validationError) {
this.error(validationError.message, {
exit: 1,
ref: validationError.ref,
suggestions: validationError.suggestions,
})
}

await this.ensureWeAreInAProjectDir()
const gameId = this.getGameId()
if (!gameId) {
Expand Down
8 changes: 8 additions & 0 deletions src/constants/godot.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
// The Godot major.minor versions the build server has export templates for.
// The server picks the patch, so this list holds no patch numbers - a user can still
// pin one, such as 4.2.1.
//
// This is the fallback. The CLI asks GET /godot/versions for the live list, and only uses
// this one when that call cannot answer. A stale entry here costs nothing until then.
// https://shipth.is/docs/guides/godot-versioning
export const SUPPORTED_GODOT_VERSIONS = ['3.6', '4.0', '4.1', '4.2', '4.3', '4.4', '4.5', '4.6', '4.7']
27 changes: 18 additions & 9 deletions src/constants/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,28 @@ import {Flags} from '@oclif/core'

export * from './cacheKeys.js'
export * from './config.js'
export * from './godot.js'

// Trim here so run() checks the same string it stores and sends. The flags with `options`
// need none - oclif checks an option list against the raw input, before parse.
const trimmed = async (input: string): Promise<string> => input.trim()

export const DetailsFlags = {
androidPackageName: Flags.string({char: 'a', description: 'Set the Android package name'}),
androidPackageName: Flags.string({char: 'a', description: 'Set the Android package name', parse: trimmed}),
// No min or max here. The parser runs before run(), so a bound set here would answer first
// with a message that carries no docs link. validateBuildNumber holds the range instead.
buildNumber: Flags.integer({char: 'b', description: 'Set the build number'}),
gameEngine: Flags.string({char: 'e', description: 'Set the game engine'}),
gameEngineVersion: Flags.string({char: 'v', description: 'Set the game engine version'}),
// The value is not imported from the GameEngine enum - importing types here has caused a
// circular import before (see the DetailsFlags fix in 4a7357f).
gameEngine: Flags.string({char: 'e', description: 'Set the game engine', options: ['godot']}),
gameEngineVersion: Flags.string({char: 'v', description: 'Set the game engine version', parse: trimmed}),
// No short char: -g is reserved CLI-wide for --gameId (see BaseGameCommand)
gcpProjectId: Flags.string({description: 'Set the GCP project ID'}),
gcpServiceAccountId: Flags.string({char: 'c', description: 'Set the GCP service account ID'}),
iosBundleId: Flags.string({char: 'i', description: 'Set the iOS bundle ID'}),
liquidGlassIconPath: Flags.string({char: 'l', description: 'Set the Liquid Glass icon path'}),
name: Flags.string({char: 'n', description: 'The name of the game'}),
semanticVersion: Flags.string({char: 's', description: 'Set the semantic version'}),
gcpProjectId: Flags.string({description: 'Set the GCP project ID', parse: trimmed}),
gcpServiceAccountId: Flags.string({char: 'c', description: 'Set the GCP service account ID', parse: trimmed}),
iosBundleId: Flags.string({char: 'i', description: 'Set the iOS bundle ID', parse: trimmed}),
liquidGlassIconPath: Flags.string({char: 'l', description: 'Set the Liquid Glass icon path', parse: trimmed}),
name: Flags.string({char: 'n', description: 'The name of the game', parse: trimmed}),
semanticVersion: Flags.string({char: 's', description: 'Set the semantic version', parse: trimmed}),
useDemoCredentials: Flags.string({
char: 'd',
description: 'Use demo credentials for this project',
Expand Down
1 change: 1 addition & 0 deletions src/utils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export * from './hooks/index.js'
export * from './query/index.js'
export * from './ship/index.js'
export * from './uuid.js'
export * from './validation.js'

export function getStageColor(stage: JobStage) {
switch (stage) {
Expand Down
Loading