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
5 changes: 5 additions & 0 deletions .changeset/best-practice-fixes.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@alchemy/cli": patch
---

Wire --no-color flag to color system, add $XDG_CONFIG_HOME support, add confirmation prompts to `apps delete` and `webhooks delete`
18 changes: 16 additions & 2 deletions src/commands/apps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { adminClientFromFlags } from "../lib/resolve.js";
import type { App } from "../lib/admin-client.js";
import { errInvalidArgs, exitWithError } from "../lib/errors.js";
import { verbose, isJSONMode, printJSON } from "../lib/output.js";
import { promptSelect } from "../lib/terminal-ui.js";
import { promptSelect, promptConfirm } from "../lib/terminal-ui.js";
import {
green,
dim,
Expand Down Expand Up @@ -319,10 +319,24 @@ export function registerApps(program: Command) {
.command("delete <id>")
.description("Delete an app")
.option("--dry-run", "Preview without executing")
.action(async (id: string, opts) => {
.option("-y, --yes", "Skip confirmation prompt")
.action(async (id: string, opts: { dryRun?: boolean; yes?: boolean }) => {
try {
if (handleDryRun(opts, "delete", { id }, `Would delete app ${id}`)) return;

if (!opts.yes && isInteractiveAllowed(program)) {
const proceed = await promptConfirm({
message: `Delete app ${id}?`,
initialValue: false,
cancelMessage: "Cancelled app deletion.",
});
if (proceed === null) return;
if (!proceed) {
console.log(` ${dim("Skipped app deletion.")}`);
return;
}
}

const admin = adminClientFromFlags(program);
await withSpinner("Deleting app…", "App deleted", () =>
admin.deleteApp(id),
Expand Down
20 changes: 18 additions & 2 deletions src/commands/webhooks.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import { Command } from "commander";
import { exitWithError } from "../lib/errors.js";
import { callNotify } from "../lib/rest.js";
import { withSpinner, printSyntaxJSON } from "../lib/ui.js";
import { withSpinner, printSyntaxJSON, dim } from "../lib/ui.js";
import { isJSONMode, printJSON } from "../lib/output.js";
import { parseRequiredJSON } from "../lib/params.js";
import { load as loadConfig } from "../lib/config.js";
import { promptConfirm } from "../lib/terminal-ui.js";
import { isInteractiveAllowed } from "../lib/interaction.js";

function resolveWebhookApiKey(
opts?: { notifyToken?: string; webhookApiKey?: string },
Expand Down Expand Up @@ -85,8 +87,22 @@ export function registerWebhooks(program: Command) {
cmd
.command("delete <webhookId>")
.description("Delete webhook")
.action(async (webhookId: string) => {
.option("-y, --yes", "Skip confirmation prompt")
.action(async (webhookId: string, opts: { yes?: boolean }) => {
try {
if (!opts.yes && isInteractiveAllowed(program)) {
const proceed = await promptConfirm({
message: `Delete webhook ${webhookId}?`,
initialValue: false,
cancelMessage: "Cancelled webhook deletion.",
});
if (proceed === null) return;
if (!proceed) {
console.log(` ${dim("Skipped webhook deletion.")}`);
return;
}
}

const token = resolveWebhookApiKey(cmd.opts());
const result = await withSpinner("Deleting webhook…", "Webhook deleted", () =>
callNotify(token, "/delete-webhook", {
Expand Down
3 changes: 2 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { setFlags, isJSONMode, quiet } from "./lib/output.js";
import { formatCommanderError } from "./lib/error-format.js";
import { load as loadConfig } from "./lib/config.js";
import { brandedHelp } from "./lib/ui.js";
import { noColor, identity, esc } from "./lib/colors.js";
import { noColor, setNoColor, identity, esc } from "./lib/colors.js";
import { registerConfig } from "./commands/config.js";
import { registerRPC } from "./commands/rpc.js";
import { registerBalance } from "./commands/balance.js";
Expand Down Expand Up @@ -327,6 +327,7 @@ program
})
.hook("preAction", () => {
const opts = program.opts();
if (opts.color === false) setNoColor(true);
const cfg = loadConfig();
setFlags({
json: opts.json,
Expand Down
15 changes: 11 additions & 4 deletions src/lib/colors.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,24 @@
export const forceColor =
"FORCE_COLOR" in process.env && process.env.FORCE_COLOR !== "0";

export const noColor =
export let noColor =
!forceColor &&
("NO_COLOR" in process.env || process.env.TERM === "dumb");

/** Called from the preAction hook when --no-color is passed. */
export function setNoColor(value: boolean): void {
noColor = value;
}

export const identity = (s: string) => s;

// Check noColor lazily (at render time) so --no-color flag takes effect
// even after color helpers have been assigned to module-level constants.
export const esc = (code: string) =>
noColor ? identity : (s: string) => `\x1b[${code}m${s}\x1b[0m`;
(s: string) => (noColor ? s : `\x1b[${code}m${s}\x1b[0m`);

export const rgb = (r: number, g: number, b: number) =>
noColor ? identity : (s: string) => `\x1b[38;2;${r};${g};${b}m${s}\x1b[39m`;
(s: string) => (noColor ? s : `\x1b[38;2;${r};${g};${b}m${s}\x1b[39m`);

export const bgRgb = (r: number, g: number, b: number) =>
noColor ? identity : (s: string) => `\x1b[48;2;${r};${g};${b}m${s}\x1b[49m`;
(s: string) => (noColor ? s : `\x1b[48;2;${r};${g};${b}m${s}\x1b[49m`);
3 changes: 2 additions & 1 deletion src/lib/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,8 @@ function getHome(): string {

export function configPath(): string {
if (process.env.ALCHEMY_CONFIG) return process.env.ALCHEMY_CONFIG;
return join(getHome(), ".config", "alchemy", "config.json");
const configHome = process.env.XDG_CONFIG_HOME || join(getHome(), ".config");
return join(configHome, "alchemy", "config.json");
}

export function configDir(): string {
Expand Down
8 changes: 8 additions & 0 deletions tests/commands/wallet.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,12 @@ describe("wallet command", () => {
const printJSON = vi.fn();
const exitWithError = vi.fn();
const originalHome = process.env.HOME;
const originalXdg = process.env.XDG_CONFIG_HOME;
const originalAlchemyCfg = process.env.ALCHEMY_CONFIG;
const tempHome = mkdtempSync(join(tmpdir(), "alchemy-wallet-test-"));
process.env.HOME = tempHome;
delete process.env.XDG_CONFIG_HOME;
delete process.env.ALCHEMY_CONFIG;

vi.doMock("../../src/lib/resolve.js", () => ({
resolveWalletKey: vi.fn(),
Expand Down Expand Up @@ -69,6 +73,10 @@ describe("wallet command", () => {

if (originalHome === undefined) delete process.env.HOME;
else process.env.HOME = originalHome;
if (originalXdg === undefined) delete process.env.XDG_CONFIG_HOME;
else process.env.XDG_CONFIG_HOME = originalXdg;
if (originalAlchemyCfg === undefined) delete process.env.ALCHEMY_CONFIG;
else process.env.ALCHEMY_CONFIG = originalAlchemyCfg;
});

it("wallet import forwards read failures to exitWithError", async () => {
Expand Down
10 changes: 10 additions & 0 deletions tests/lib/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,17 +120,27 @@ describe("configPath", () => {

describe("config save/load", () => {
let origHome: string | undefined;
let origXdg: string | undefined;
let origAlchemyCfg: string | undefined;
let tmpDir: string;

beforeEach(() => {
origHome = process.env.HOME;
origXdg = process.env.XDG_CONFIG_HOME;
origAlchemyCfg = process.env.ALCHEMY_CONFIG;
tmpDir = join(tmpdir(), `alchemy-test-${randomUUID()}`);
mkdirSync(tmpDir, { recursive: true });
process.env.HOME = tmpDir;
delete process.env.XDG_CONFIG_HOME;
delete process.env.ALCHEMY_CONFIG;
});

afterEach(() => {
process.env.HOME = origHome;
if (origXdg === undefined) delete process.env.XDG_CONFIG_HOME;
else process.env.XDG_CONFIG_HOME = origXdg;
if (origAlchemyCfg === undefined) delete process.env.ALCHEMY_CONFIG;
else process.env.ALCHEMY_CONFIG = origAlchemyCfg;
});

it("saves and loads config", () => {
Expand Down
Loading