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
164 changes: 117 additions & 47 deletions src/cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,20 +21,28 @@ import { version } from "../package.json";
import { z } from "zod";
import semver from "semver";
import { KnownError } from "./error";
import { findProjectFile } from "./project";

/** The subset of the Context object that's used in the cache.
* Needed because we make use of the cache in app.ts before the context object is constructed
*/
type CacheCtx = Pick<Context, "path" | "fs" | "os" | "process" | "env">;

export type CacheType =
/** Use a caller-specified absolute path as the local cache directory. Disables the shared cache. */
| { type: "absolute"; path: string }
| { type: "infer"; projectDir: string }
/**
* Infer the cache directory from GAMEMAKER_CLI_CACHE_DIR or current working dir.
* Shared cache is allowed unless GAMEMAKER_CLI_CACHE_DIR or CI env vars are set. projectDir can be set if
* the project is not in the working directory.
*/
| { type: "infer"; projectDir?: string }
/** Use a throwaway directory under the OS temp dir. Disables the shared cache. */
| { type: "temporary" }
/** Skip the local cache entirely and use only the shared system-wide cache. */
| { type: "shared-only" };

export class Cache {
private _localOnly: boolean;
private _sharedPath:
| {
type: "not-allowed";
Expand All @@ -45,30 +53,45 @@ export class Cache {
}
| {
type: "not-initialized";
intendedPath: string;
};

// Local .gmcache directory
private _localPath:
| {
initialized: true;
type: "initialized";
path: string;
}
| {
initialized: false;
cacheType: CacheType;
};
type: "not-initialized";
intendedPath: string;
}
| { type: "not-allowed" };

static async initLazy(ctx: CacheCtx, cacheType: CacheType): Promise<Cache> {
const { path: localPath, allowShared } = await resolveLocalCachePath(
ctx,
cacheType,
);
return new Cache(
localPath,
allowShared ? resolveSharedCachePath(ctx) : undefined,
);
}

constructor(ctx: CacheCtx, cacheType: CacheType) {
this._localPath = { initialized: false, cacheType };
// We don't allow using the shared cache if a user has specified an absolute or temporary
// path or if inside a CI runner.
this._localOnly =
cacheType.type === "absolute" ||
cacheType.type === "temporary" ||
ctx.env.CI === true;
this._sharedPath = this._localOnly
? { type: "not-allowed" }
: { type: "not-initialized" };
private constructor(
localPath: string | undefined,
sharedPath: string | undefined,
) {
if (localPath) {
this._localPath = { type: "not-initialized", intendedPath: localPath };
} else {
this._localPath = { type: "not-allowed" };
}
if (sharedPath) {
this._sharedPath = { type: "not-initialized", intendedPath: sharedPath };
} else {
this._sharedPath = { type: "not-allowed" };
}
}

private async initCachePath(ctx: CacheCtx, cachePath: string): Promise<void> {
Expand Down Expand Up @@ -100,30 +123,15 @@ export class Cache {
}

private async initLocal(ctx: CacheCtx): Promise<boolean> {
if (this._localPath.initialized) {
if (this._localPath.type === "initialized") {
return true;
}

// Resolve the path based on the cache type
const cacheType = this._localPath.cacheType;
let cachePath: string;
if (cacheType.type === "absolute") {
cachePath = cacheType.path;
} else if (cacheType.type === "infer" && ctx.env.GAMEMAKER_CLI_CACHE_DIR) {
cachePath = ctx.env.GAMEMAKER_CLI_CACHE_DIR;
} else if (cacheType.type === "infer") {
cachePath = ctx.path.join(cacheType.projectDir, ".gmcache");
} else if (cacheType.type === "temporary") {
cachePath = ctx.path.join(ctx.os.tmpdir(), "gm-cli-cache");
} else if (cacheType.type === "shared-only") {
if (this._localPath.type === "not-allowed") {
return false;
} else {
cacheType satisfies never;
throw new Error("unreachable");
}

await this.initCachePath(ctx, cachePath);
this._localPath = { initialized: true, path: cachePath };
const path = this._localPath.intendedPath;
await this.initCachePath(ctx, path);
this._localPath = { type: "initialized", path };
return true;
}

Expand All @@ -142,19 +150,23 @@ export class Cache {
}

/** Initializes and returns the local path, or undefined if local access is not allowed for this cache type. */
public async getLocalPathStrict(ctx: CacheCtx): Promise<string | undefined> {
public async _getInternalLocalPath(
ctx: CacheCtx,
): Promise<string | undefined> {
if (!(await this.initLocal(ctx))) {
// Not allowed to use shared cache
return undefined;
}
if (!this._localPath.initialized) {
if (this._localPath.type !== "initialized") {
throw new Error("Unreachable: local cache should be initialized");
}
return this._localPath.path;
}

/** Initializes and returns the shared path, or undefined if shared access is not allowed for this cache. */
public async getSharedPathStrict(ctx: CacheCtx): Promise<string | undefined> {
public async _getInternalSharedPath(
ctx: CacheCtx,
): Promise<string | undefined> {
if (!(await this.initShared(ctx))) {
// Not allowed to use shared cache
return undefined;
Expand All @@ -168,11 +180,11 @@ export class Cache {
async getSubDirPath(
ctx: CacheCtx,
name: string,
{ allowedToBeShared }: { allowedToBeShared: boolean } = {
allowedToBeShared: false,
{ preferShared }: { preferShared: boolean } = {
preferShared: false,
},
): Promise<string> {
if (allowedToBeShared && !this._localOnly) {
if (preferShared && this._sharedPath.type !== "not-allowed") {
return this.getSubDirPathShared(ctx, name);
}
return this.getSubDirPathLocal(ctx, name);
Expand All @@ -183,8 +195,12 @@ export class Cache {
name: string,
): Promise<string> {
// lazily create the cache directory if needed
await this.initLocal(ctx);
if (!this._localPath.initialized) {
if (!(await this.initLocal(ctx))) {
throw new Error(
"Invariant broken: getSubDirPathLocal should only be called when we know that it's allowed to use a local cache",
);
}
if (this._localPath.type !== "initialized") {
throw Error("Unreachable: local cache should be initialized.");
}

Expand All @@ -197,7 +213,11 @@ export class Cache {
ctx: CacheCtx,
name: string,
): Promise<string> {
await this.initShared(ctx);
if (!(await this.initShared(ctx))) {
throw new Error(
"Invariant broken: getSubDirPathShared should only be called when we know that it's allowed to use a shared cache",
);
}
if (this._sharedPath.type !== "initialized") {
throw Error("Unreachable: shared cache should be initialized.");
}
Expand All @@ -208,6 +228,56 @@ export class Cache {
}
}

/** Resolve the path based on the cache type and decide if we are allowed to
* used the shared cache as well.
*/
async function resolveLocalCachePath(
ctx: CacheCtx,
cacheType: CacheType,
): Promise<{ path?: string; allowShared: boolean }> {
const isCi = ctx.env.CI;
if (cacheType.type === "absolute") {
return { path: cacheType.path, allowShared: false };
}
if (cacheType.type === "infer" && ctx.env.GAMEMAKER_CLI_CACHE_DIR) {
return { path: ctx.env.GAMEMAKER_CLI_CACHE_DIR, allowShared: false };
}
if (cacheType.type === "infer" && cacheType.projectDir !== undefined) {
return {
path: ctx.path.join(cacheType.projectDir, ".gmcache"),
allowShared: !isCi,
};
}
// Try using cwd if we can find a .yyp file
if (cacheType.type === "infer") {
const cwd = ctx.process.cwd();
const projectPath = await findProjectFile(ctx, cwd);
if (projectPath === undefined) {
throw new KnownError(
"No .yyp project file found in the current directory",
);
}
return { path: ctx.path.join(cwd, ".gmcache"), allowShared: !isCi };
}
if (cacheType.type === "temporary") {
return {
path: ctx.path.join(ctx.os.tmpdir(), "gm-cli-cache"),
allowShared: false,
};
}
if (cacheType.type === "shared-only") {
if (isCi) {
throw new Error(
"Invariant broken: Can't use a shared-only cache in CI since it does not allow shared caches",
);
}
return { path: undefined, allowShared: !isCi };
}

cacheType satisfies never;
throw new Error("unreachable");
}

function resolveSharedCachePath(ctx: CacheCtx): string {
const home = ctx.os.homedir();
const platform = ctx.os.platform();
Expand Down
4 changes: 2 additions & 2 deletions src/commands/cache/clean-impl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,8 @@ export default async function (
): Promise<void> {
validateFlags(flags);
const cache = await setupCache(this, flags);
const localPath = await cache.getLocalPathStrict(this);
const sharedPath = await cache.getSharedPathStrict(this);
const localPath = await cache._getInternalLocalPath(this);
const sharedPath = await cache._getInternalSharedPath(this);
await cleanPath(this, "Shared cache", sharedPath);
await cleanPath(this, "Local cache", localPath);
}
2 changes: 1 addition & 1 deletion src/commands/cache/command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ export async function setupCache(
}
}

return new Cache(ctx, cacheType);
return Cache.initLazy(ctx, cacheType);
}

export function validateFlags(flags: CacheFlags): void {
Expand Down
5 changes: 3 additions & 2 deletions src/commands/cache/info-impl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,9 @@ export default async function (
): Promise<void> {
validateFlags(flags);
const cache = await setupCache(this, flags);
const localPath = await cache.getLocalPathStrict(this);
const sharedPath = await cache.getSharedPathStrict(this);
// TODO: we may want to use lazy versions of these functions here instead
const localPath = await cache._getInternalLocalPath(this);
const sharedPath = await cache._getInternalSharedPath(this);
this.process.stdout.write(chalk.bold("Shared cache\n"));
this.process.stdout.write(
`${sharedPath ?? "Not used when explicit --cache-dir is set"}\n`,
Expand Down
6 changes: 4 additions & 2 deletions src/commands/gxgames/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { KnownError } from "~/error";
import crypto from "node:crypto";
import type { Context } from "~/context";
import { readAuth, writeAuth } from "./link";
import { Cache } from "~/cache";
import {
AUTH_BASE,
CLIENT_ID,
Expand Down Expand Up @@ -98,7 +99,8 @@ export function createAuthManager(ctx: Context): AuthManager {
}

export async function authenticate(ctx: Context): Promise<string> {
const cached = await readAuth(ctx);
const cache = await Cache.initLazy(ctx, { type: "infer" });
const cached = await readAuth(ctx, cache);
if (cached && cached.expiresAt > Date.now()) {
return cached.accessToken;
}
Expand All @@ -121,7 +123,7 @@ export async function authenticate(ctx: Context): Promise<string> {
log.message("Waiting for browser login...");
const code = await codePromise;
const { accessToken, expiresAt } = await exchangeCodeForToken(code, state);
await writeAuth(ctx, { accessToken, expiresAt });
await writeAuth(ctx, { accessToken, expiresAt }, cache);
log.success("Authenticated");

return accessToken;
Expand Down
4 changes: 3 additions & 1 deletion src/commands/gxgames/link-impl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import { createAuthManager } from "./auth";
import { getApiClient } from "./client";
import { unwrapResponse } from "./api/helpers";
import { writeLink } from "./link";
import { Cache } from "~/cache";

export default async function (
this: Context,
Expand Down Expand Up @@ -103,6 +104,7 @@ export default async function (
}
}

await writeLink(this, { studioId, gameId });
const cache = await Cache.initLazy(this, { type: "infer" });
await writeLink(this, { studioId, gameId }, cache);
p.log.success(`Linked to studio ${studioId}, game ${gameId}`);
}
Loading
Loading