diff --git a/.gitignore b/.gitignore index f3fc30ef..f19fa8ba 100644 --- a/.gitignore +++ b/.gitignore @@ -59,6 +59,7 @@ typings/ # Optional npm cache directory .npm +.bun # Optional eslint cache .eslintcache @@ -112,3 +113,7 @@ dist # TernJS port file .tern-port + +# Bun lock +bun.lock +bun.lockb \ No newline at end of file diff --git a/src/commands/login.ts b/src/commands/login.ts index a0a5fe90..63552aa9 100644 --- a/src/commands/login.ts +++ b/src/commands/login.ts @@ -1,7 +1,23 @@ +import { + type RESTGetApiUserResult, + RouteBases, + Routes, +} from "@discloudapp/api-types/v2"; +import { styleText } from "util"; import { type ICommand } from "../interfaces/command"; import { promptApiToken } from "../prompts/discloud/api"; -interface CommandArgs { } +interface CommandArgs {} + +function formatLocale(locale: string) { + try { + return ( + new Intl.DisplayNames([locale], { type: "language" }).of(locale) ?? locale + ); + } catch { + return locale; + } +} export default >{ name: "login", @@ -10,16 +26,51 @@ export default >{ async run(core, _args) { const token = await promptApiToken(); - const apinner = core.print.spin(); + const spinner = core.print.spin("Validating token..."); - const isValidToken = await core.api.validateToken(token); + let userData: RESTGetApiUserResult["user"] | undefined; + let isValid = false; - if (isValidToken) { - core.config.set("token", token); + try { + const url = new URL(RouteBases.api + Routes.user()); + const response = (await (core.api as any).request(url, { + headers: { + "api-token": token, + ...((core.api as any).options?.userAgent + ? { "User-Agent": (core.api as any).options.userAgent.toString() } + : {}), + }, + })) as RESTGetApiUserResult; + if (response.status === "ok" && response.user) { + isValid = true; + userData = response.user; + } + } catch {} - return apinner.succeed("Discloud token successfully validated"); + if (!isValid) { + return spinner.fail("Invalid token"); } - apinner.fail("Invalid Discloud token"); + core.config.set("token", token); + spinner.succeed("Logged in successfully"); + + if (userData) { + const u = userData; + process.stdout.write("\n"); + process.stdout.write(` ${styleText("dim", "ID")} ${u.userID}\n`); + process.stdout.write( + ` ${styleText("dim", "Plan")} ${styleText("magenta", u.plan)}\n`, + ); + process.stdout.write( + ` ${styleText("dim", "Locale")} ${u.locale} (${formatLocale(u.locale)})\n`, + ); + process.stdout.write( + ` ${styleText("dim", "RAM")} ${u.ramUsedMb} / ${u.totalRamMb} MB\n`, + ); + process.stdout.write( + ` ${styleText("dim", "Apps")} ${u.apps?.length ?? 0}\n`, + ); + process.stdout.write("\n"); + } }, }; diff --git a/src/prompts/discloud/api.ts b/src/prompts/discloud/api.ts index 01eec4e1..44cf4bd5 100644 --- a/src/prompts/discloud/api.ts +++ b/src/prompts/discloud/api.ts @@ -5,9 +5,13 @@ import { promptTrier } from "../utils"; export async function promptApiToken(): Promise { return promptTrier(() => password({ - message: "Your Discloud token:", - mask: true, - validate: tokenIsDiscloudJwt, + message: " API token:", + mask: "●", + validate: (value) => { + if (!tokenIsDiscloudJwt(value)) + return "Invalid token format. Copy it from discloud.com/dashboard → API Token"; + return true; + }, })); } @@ -23,4 +27,4 @@ export async function promptUserLocale(): Promise { message: "Choose your locale", choices: API_LOCALES, })); -} +} \ No newline at end of file diff --git a/src/services/discloud/REST.ts b/src/services/discloud/REST.ts index f6784f07..433843b3 100644 --- a/src/services/discloud/REST.ts +++ b/src/services/discloud/REST.ts @@ -7,6 +7,14 @@ import RateLimit from "./RateLimit"; import { type InternalRequestData, type RequestData, type RequestOptions, type RESTOptions, type RouteLike } from "./types"; import { tokenIsDiscloudJwt } from "./utils"; +const REQUEST_TIMEOUT_MS = 30_000; +const MAX_RETRIES = 3; +const RETRY_BASE_DELAY_MS = 500; + +function sleep(ms: number) { + return new Promise(resolve => setTimeout(resolve, ms)); +} + export default class REST implements IApi { readonly options: Partial; readonly rateLimiter: RateLimit; @@ -82,22 +90,60 @@ export default class REST implements IApi { if (requestToken) this.rateLimiter.verify(requestToken); - const response = await fetch(url, config); + const isReadonly = !config.method || config.method === RequestMethod.Get; + const hasBody = config.method === RequestMethod.Post || config.method === RequestMethod.Put; + const retries = isReadonly ? MAX_RETRIES : 1; - if (requestToken) this.#handleResponseHeaders(response.headers, requestToken); + let lastError: unknown; - const responseBody = await this.#resolveResponseBody(response); + for (let attempt = 0; attempt < retries; attempt++) { + if (attempt > 0) { + const delay = RETRY_BASE_DELAY_MS * 2 ** (attempt - 1); + this.core.print.debug("Retrying request (attempt %d/%d) after %dms...", attempt + 1, retries, delay); + await sleep(delay); + } - if (!response.ok) - throw new DiscloudAPIError( - responseBody, - response.status, - config.method ?? "GET", - pathname, - config.body, - ); + const controller = hasBody ? null : new AbortController(); + const timeout = controller ? setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS) : null; - return responseBody; + try { + const response = await fetch(url, { ...config, ...(controller ? { signal: controller.signal } : {}) }); + + if (requestToken) this.#handleResponseHeaders(response.headers, requestToken); + + if (!response.ok && response.status >= 500 && isReadonly && attempt < retries - 1) { + lastError = response; + continue; + } + + const responseBody = await this.#resolveResponseBody(response); + + if (!response.ok) + throw new DiscloudAPIError( + responseBody, + response.status, + config.method ?? "GET", + pathname, + config.body, + ); + + return responseBody; + } catch (err) { + if ((err as Error)?.name === "AbortError") { + lastError = new Error(`Request timed out after ${REQUEST_TIMEOUT_MS / 1000}s: ${pathname}`); + if (attempt < retries - 1) continue; + throw lastError; + } + if (err instanceof DiscloudAPIError) throw err; + lastError = err; + if (attempt < retries - 1) continue; + throw err; + } finally { + if (timeout) clearTimeout(timeout); + } + } + + throw lastError; } #getHeaderValue(headers: RequestOptions["headers"], headerKey: any) { @@ -199,4 +245,4 @@ export default class REST implements IApi { #handleResponseHeaders(headers: Headers, context: string) { this.rateLimiter.limit(headers, context); } -} +} \ No newline at end of file diff --git a/src/stores/FsJson.ts b/src/stores/FsJson.ts index 5c5e4803..b3868be8 100644 --- a/src/stores/FsJson.ts +++ b/src/stores/FsJson.ts @@ -15,10 +15,13 @@ const defaultOptions: Options = { const defaultNestChar: DefaultNestChar = "."; +const WRITE_DEBOUNCE_MS = 50; + export default class FsJsonStore> implements IStore { readonly #data: T; readonly options: Options; readonly #decoding: BufferEncoding = "utf8"; + #writeTimer: ReturnType | null = null; constructor(readonly path: string, options?: Partial) { this.options = Object.assign(defaultOptions, options); @@ -85,16 +88,36 @@ export default class FsJsonStore> implements IStore { + writeFileSync(this.path, this.#encode(this.#data), this.#decoding); + this.#writeTimer = null; + }, WRITE_DEBOUNCE_MS); + } + + #flushWrite(data?: Partial) { + if (this.#writeTimer) { + clearTimeout(this.#writeTimer); + this.#writeTimer = null; + } + writeFileSync(this.path, this.#encode(Object.assign(this.#data, data)), this.#decoding); + } + clear() { // @ts-expect-error ts(2540) this.#data = {}; - this.update(this.#data); + this.#flushWrite(this.#data); return this; } destroy() { + if (this.#writeTimer) { + clearTimeout(this.#writeTimer); + this.#writeTimer = null; + } try { rmSync(this.path); } catch { } return this; @@ -104,7 +127,7 @@ export default class FsJsonStore> implements IStore> implements IStore) { - writeFileSync(this.path, this.#encode(Object.assign(this.#data, data)), this.#decoding); + this.#flushWrite(data); return this.data; } -} +} \ No newline at end of file diff --git a/src/structures/filesystem/fs.ts b/src/structures/filesystem/fs.ts index 56a37fdd..c20aafca 100644 --- a/src/structures/filesystem/fs.ts +++ b/src/structures/filesystem/fs.ts @@ -1,11 +1,11 @@ import { Ignore } from "@discloudapp/util"; -import { spawn } from "child_process"; -import { on } from "events"; +import { on, once } from "events"; import { existsSync, type Dirent } from "fs"; import { readdir, readFile, writeFile } from "fs/promises"; import { globIterate } from "glob"; import { type } from "os"; import { join, relative } from "path"; +import { spawn } from "child_process"; import type Core from "../../core"; import { type FileSystemReadDirWithFileTypesOptions, type IFileSystem } from "../../interfaces/filesystem"; import { CONFIG_FILENAME } from "../../services/discloud/constants"; @@ -16,6 +16,17 @@ export default class FileSystem implements IFileSystem { protected readonly core: Core, ) { } + readonly #ignoreCache = new Map(); + + async #getIgnorePatterns(cwd: string): Promise { + if (this.#ignoreCache.has(cwd)) return this.#ignoreCache.get(cwd)!; + + const ignoreModule = new Ignore(CONFIG_FILENAME); + const patterns = await ignoreModule.getIgnorePatterns(cwd); + this.#ignoreCache.set(cwd, patterns); + return patterns; + } + asAbsolutePath(path: string, cwd: string = this.core.workspaceFolder): string { return join(cwd, path); } @@ -33,8 +44,7 @@ export default class FileSystem implements IFileSystem { } async *globIterate(pattern: string[] | string, cwd: string = this.core.workspaceFolder) { - const ignoreModule = new Ignore(CONFIG_FILENAME); - const ignore = await ignoreModule.getIgnorePatterns(cwd); + const ignore = await this.#getIgnorePatterns(cwd); yield* globIterate(pattern, { cwd, @@ -48,8 +58,7 @@ export default class FileSystem implements IFileSystem { protected async *_fsGlobIterate(pattern: string[] | string, cwd: string = this.core.workspaceFolder) { const { glob } = await import("fs/promises"); - const ignoreModule = new Ignore(CONFIG_FILENAME); - const exclude = await ignoreModule.getIgnorePatterns(cwd); + const exclude = await this.#getIgnorePatterns(cwd); yield* glob(pattern, { cwd, @@ -88,6 +97,13 @@ export default class FileSystem implements IFileSystem { timeout: MINUTE_IN_MILLISECONDS, }); - for await (const [chunk] of on(child.stdout, "data", { close: ["end"] })) yield chunk; + const stderrChunks: Buffer[] = []; + child.stderr?.on("data", (chunk: Buffer) => stderrChunks.push(chunk)); + + try { + for await (const [chunk] of on(child.stdout, "data", { close: ["end"] })) yield chunk; + } finally { + await once(child, "close").catch(() => {}); + } } -} +} \ No newline at end of file diff --git a/src/structures/filesystem/zip.ts b/src/structures/filesystem/zip.ts index 9a9ac80d..7fccfbec 100644 --- a/src/structures/filesystem/zip.ts +++ b/src/structures/filesystem/zip.ts @@ -6,6 +6,8 @@ import type Core from "../../core"; import { type IZip } from "../../interfaces/zip"; import { normalizeGlobPattern } from "../../utils/glob"; +const PARALLEL_BATCH_SIZE = 20; + export default class Zip implements IZip { declare readonly zip: AdmZip; @@ -20,20 +22,22 @@ export default class Zip implements IZip { async appendFiles(files: string[], cwd: string = process.cwd()) { if (!files?.length) return; - for (let i = 0; i < files.length; i++) { - const zipName = files[i]; + for (let i = 0; i < files.length; i += PARALLEL_BATCH_SIZE) { + const batch = files.slice(i, i + PARALLEL_BATCH_SIZE); - const localPath = join(cwd, zipName); + await Promise.all(batch.map(async (zipName) => { + const localPath = join(cwd, zipName); - let fileStat; - try { fileStat = await stat(localPath); } - catch { continue; } + let fileStat; + try { fileStat = await stat(localPath); } + catch { return; } - if (!fileStat.isFile()) continue; + if (!fileStat.isFile()) return; - const buffer = await readFile(localPath); + const buffer = await readFile(localPath); - this.zip.addFile(zipName, buffer, undefined, fileStat.mode); + this.zip.addFile(zipName, buffer, undefined, fileStat.mode); + })); } } @@ -44,20 +48,31 @@ export default class Zip implements IZip { this.core.print.debug("Normalized glob pattern: %s", pattern); + const paths: string[] = []; for await (const zipName of globIterate(pattern, cwd)) { - const localPath = join(cwd, zipName); + paths.push(zipName); + } + + this.core.print.debug("Found %d files, zipping in parallel batches of %d", paths.length, PARALLEL_BATCH_SIZE); + + for (let i = 0; i < paths.length; i += PARALLEL_BATCH_SIZE) { + const batch = paths.slice(i, i + PARALLEL_BATCH_SIZE); - let fileStat; - try { fileStat = await stat(localPath); } - catch { continue; } + await Promise.all(batch.map(async (zipName) => { + const localPath = join(cwd, zipName); - if (!fileStat.isFile()) continue; + let fileStat; + try { fileStat = await stat(localPath); } + catch { return; } - this.core.print.debug("File found: %s", zipName); + if (!fileStat.isFile()) return; - const buffer = await readFile(localPath); + this.core.print.debug("File found: %s", zipName); - this.zip.addFile(zipName, buffer, undefined, fileStat.mode); + const buffer = await readFile(localPath); + + this.zip.addFile(zipName, buffer, undefined, fileStat.mode); + })); } this.core.print.debug("Successfully zipped"); @@ -66,20 +81,28 @@ export default class Zip implements IZip { protected async _fsGlob(pattern: string[] | string, cwd: string = process.cwd()) { const { fsGlobIterate } = await import("@discloudapp/util"); + const dirents: Array<{ parentPath: string; name: string }> = []; for await (const dirent of fsGlobIterate(pattern, { cwd, withFileTypes: true })) { - const localPath = join(dirent.parentPath, dirent.name); + dirents.push({ parentPath: dirent.parentPath, name: dirent.name }); + } + + for (let i = 0; i < dirents.length; i += PARALLEL_BATCH_SIZE) { + const batch = dirents.slice(i, i + PARALLEL_BATCH_SIZE); - let fileStat; - try { fileStat = await stat(localPath); } - catch { continue; } + await Promise.all(batch.map(async ({ parentPath, name }) => { + const localPath = join(parentPath, name); - if (!fileStat.isFile()) continue; + let fileStat; + try { fileStat = await stat(localPath); } + catch { return; } - const buffer = await readFile(localPath); + if (!fileStat.isFile()) return; - const zipName = relative(cwd, localPath); + const buffer = await readFile(localPath); + const zipName = relative(cwd, localPath); - this.zip.addFile(zipName, buffer, undefined, fileStat.mode); + this.zip.addFile(zipName, buffer, undefined, fileStat.mode); + })); } } @@ -90,4 +113,4 @@ export default class Zip implements IZip { writeZip(targetFileName?: string) { return this.zip.writeZipPromise(targetFileName, { overwrite: true }); } -} +} \ No newline at end of file