Skip to content
Open
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
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ typings/

# Optional npm cache directory
.npm
.bun

# Optional eslint cache
.eslintcache
Expand Down Expand Up @@ -112,3 +113,7 @@ dist

# TernJS port file
.tern-port

# Bun lock
bun.lock
bun.lockb
65 changes: 58 additions & 7 deletions src/commands/login.ts
Original file line number Diff line number Diff line change
@@ -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 <ICommand<CommandArgs>>{
name: "login",
Expand All @@ -10,16 +26,51 @@ export default <ICommand<CommandArgs>>{
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");
}
},
};
12 changes: 8 additions & 4 deletions src/prompts/discloud/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,13 @@ import { promptTrier } from "../utils";

export async function promptApiToken(): Promise<string> {
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;
},
}));
}

Expand All @@ -23,4 +27,4 @@ export async function promptUserLocale(): Promise<string> {
message: "Choose your locale",
choices: API_LOCALES,
}));
}
}
72 changes: 59 additions & 13 deletions src/services/discloud/REST.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,14 @@
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<void>(resolve => setTimeout(resolve, ms));
}

export default class REST implements IApi {
readonly options: Partial<RESTOptions>;
readonly rateLimiter: RateLimit;
Expand Down Expand Up @@ -82,22 +90,60 @@

if (requestToken) this.rateLimiter.verify(requestToken);

const response = await fetch(url, config);
const isReadonly = !config.method || config.method === RequestMethod.Get;

Check failure on line 93 in src/services/discloud/REST.ts

View workflow job for this annotation

GitHub Actions / lint

The two values in this comparison do not have a shared enum type
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) {
Expand Down Expand Up @@ -199,4 +245,4 @@
#handleResponseHeaders(headers: Headers, context: string) {
this.rateLimiter.limit(headers, context);
}
}
}
33 changes: 28 additions & 5 deletions src/stores/FsJson.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,13 @@ const defaultOptions: Options = {

const defaultNestChar: DefaultNestChar = ".";

const WRITE_DEBOUNCE_MS = 50;

export default class FsJsonStore<T extends Record<any, any>> implements IStore<T> {
readonly #data: T;
readonly options: Options;
readonly #decoding: BufferEncoding = "utf8";
#writeTimer: ReturnType<typeof setTimeout> | null = null;

constructor(readonly path: string, options?: Partial<Options>) {
this.options = Object.assign(defaultOptions, options);
Expand Down Expand Up @@ -85,16 +88,36 @@ export default class FsJsonStore<T extends Record<any, any>> implements IStore<T
}
}

#scheduleWrite() {
if (this.#writeTimer) clearTimeout(this.#writeTimer);
this.#writeTimer = setTimeout(() => {
writeFileSync(this.path, this.#encode(this.#data), this.#decoding);
this.#writeTimer = null;
}, WRITE_DEBOUNCE_MS);
}

#flushWrite(data?: Partial<T>) {
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;
Expand All @@ -104,7 +127,7 @@ export default class FsJsonStore<T extends Record<any, any>> implements IStore<T
delete(key: string) {
Reflect.deleteProperty(this.#data, key);

this.update();
this.#scheduleWrite();

return this;
}
Expand All @@ -121,14 +144,14 @@ export default class FsJsonStore<T extends Record<any, any>> implements IStore<T
set(key: string, value: unknown) {
this.#setNestedData(key, value);

this.update(this.#data);
this.#scheduleWrite();

return this;
}

update(data?: Partial<T>) {
writeFileSync(this.path, this.#encode(Object.assign(this.#data, data)), this.#decoding);
this.#flushWrite(data);

return this.data;
}
}
}
32 changes: 24 additions & 8 deletions src/structures/filesystem/fs.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -16,6 +16,17 @@ export default class FileSystem implements IFileSystem {
protected readonly core: Core,
) { }

readonly #ignoreCache = new Map<string, string[]>();

async #getIgnorePatterns(cwd: string): Promise<string[]> {
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);
}
Expand All @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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(() => {});
}
}
}
}
Loading
Loading