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
6 changes: 5 additions & 1 deletion ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,10 @@ memberstack-cli/
│ │ ├── records.ts # Record CRUD, query, find, import/export, bulk ops
│ │ ├── skills.ts # Agent skill add/remove (wraps npx skills)
│ │ ├── providers.ts # Auth provider management (list, configure, remove)
│ │ ├── reset.ts # Delete local data files and clear authentication
│ │ ├── sso.ts # SSO app management (list, create, update, delete)
│ │ ├── tables.ts # Data table CRUD, describe
│ │ ├── update.ts # Self-update CLI via detected package manager
│ │ ├── users.ts # App user management (list, get, add, remove, update-role)
│ │ └── whoami.ts # Show current app and user
│ │
Expand All @@ -52,8 +54,10 @@ memberstack-cli/
│ │ ├── records.test.ts
│ │ ├── skills.test.ts
│ │ ├── providers.test.ts
│ │ ├── reset.test.ts
│ │ ├── sso.test.ts
│ │ ├── tables.test.ts
│ │ ├── update.test.ts
│ │ ├── users.test.ts
│ │ └── whoami.test.ts
│ │
Expand Down Expand Up @@ -103,7 +107,7 @@ Each file exports a Commander `Command` with subcommands. Most commands follow t
4. Output results via `printTable()`, `printRecord()`, or `printSuccess()`
5. Catch errors and set `process.exitCode = 1`

The `skills` command is an exception — it wraps `npx skills` (child process) to add/remove agent skills instead of calling the GraphQL API.
The `skills` and `update` commands are exceptions — they wrap child processes (`npx skills` and the user's package manager respectively) instead of calling the GraphQL API. The `reset` command performs local cleanup only (deletes `members.json`/`members.csv` and clears stored auth tokens).

Repeatable options use a `collect` helper: `(value, previous) => [...previous, value]`.

Expand Down
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,10 @@ memberstack skills add memberstack-cli
| `custom-fields` | List, create, update, and delete custom fields |
| `users` | List, get, add, remove, and update roles for app users |
| `providers` | List, configure, and remove auth providers (e.g. Google) |
| `sso` | List, create, update, and delete SSO apps |
| `skills` | Add/remove agent skills for Claude Code and Codex |
| `update` | Update the CLI to the latest version |
| `reset` | Delete local data files and clear authentication |

For full command details and usage, see the [Command Reference](https://memberstack-cli.flashbrew.digital/docs/commands).

Expand Down
2 changes: 1 addition & 1 deletion src/commands/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -209,7 +209,7 @@ authCommand
` ${pc.bold("Status:")} ${pc.yellow("Not logged in")}\n`
);
process.stderr.write(
`\n Run ${pc.cyan("memberstack-cli auth login")} to authenticate.\n`
`\n Run ${pc.cyan("memberstack auth login")} to authenticate.\n`
);
process.stderr.write("\n");
return;
Expand Down
85 changes: 85 additions & 0 deletions src/commands/reset.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import { rm } from "node:fs/promises";
import { resolve } from "node:path";
import { createInterface } from "node:readline";
import { Command } from "commander";
import pc from "picocolors";
import { clearTokens } from "../lib/token-storage.js";
import { printError, printSuccess } from "../lib/utils.js";

const FILES_TO_DELETE = ["members.json", "members.csv"];

const confirm = (message: string): Promise<boolean> =>
new Promise((resolve) => {
const rl = createInterface({
input: process.stdin,
output: process.stderr,
});
rl.question(message, (answer) => {
rl.close();
const normalized = answer.trim().toLowerCase();
resolve(normalized === "y" || normalized === "yes");
});
});

const tryDelete = async (filePath: string): Promise<boolean> => {
try {
await rm(filePath);
return true;
} catch {
return false;
}
};

export const resetCommand = new Command("reset")
.description("Delete local data files and clear authentication")
.option("-f, --force", "Skip confirmation prompt")
.action(async (opts: { force?: boolean }) => {
if (!opts.force) {
process.stderr.write("\n");
process.stderr.write(` ${pc.bold("This will:")}\n`);
process.stderr.write(
` - Delete ${FILES_TO_DELETE.join(", ")} (if present)\n`
);
process.stderr.write(" - Clear stored authentication tokens\n");
process.stderr.write("\n");

const proceed = await confirm(` ${pc.bold("Continue?")} (y/n) `);
if (!proceed) {
process.stderr.write("\n Aborted.\n\n");
return;
}
process.stderr.write("\n");
}

try {
const results: string[] = [];

for (const file of FILES_TO_DELETE) {
const fullPath = resolve(file);
const deleted = await tryDelete(fullPath);
if (deleted) {
results.push(`Deleted ${file}`);
}
}

await clearTokens();
results.push("Cleared authentication tokens");

for (const result of results) {
printSuccess(` ${result}`);
}

if (results.length === 1) {
process.stderr.write(
`\n ${pc.dim("No local data files found to delete.")}\n`
);
}

process.stderr.write("\n");
} catch (error) {
printError(
error instanceof Error ? error.message : "An unknown error occurred"
);
process.exitCode = 1;
}
});
77 changes: 77 additions & 0 deletions src/commands/update.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { exec } from "node:child_process";
import { promisify } from "node:util";
import { Command } from "commander";
import pc from "picocolors";
import yoctoSpinner from "yocto-spinner";
import { printError, printSuccess } from "../lib/utils.js";

const execAsync = promisify(exec);

declare const __VERSION__: string | undefined;
const currentVersion = typeof __VERSION__ !== "undefined" ? __VERSION__ : "dev";

const PACKAGE_NAME = "memberstack-cli";
const DISPLAY_NAME = "Memberstack CLI";

type PackageManager = "bun" | "npm" | "pnpm" | "yarn";

const detectPackageManager = (): PackageManager => {
const scriptPath = process.argv[1] ?? "";
if (scriptPath.includes("/pnpm/") || scriptPath.includes("/.pnpm/")) {
return "pnpm";
}
if (scriptPath.includes("/yarn/")) {
return "yarn";
}
if (scriptPath.includes("/.bun/") || scriptPath.includes("/bun/")) {
return "bun";
}
return "npm";
};

const getUpdateCommand = (pm: PackageManager): string => {
switch (pm) {
case "bun": {
return `bun install -g ${PACKAGE_NAME}@latest`;
}
case "pnpm": {
return `pnpm add -g ${PACKAGE_NAME}@latest`;
}
case "yarn": {
return `yarn global add ${PACKAGE_NAME}@latest`;
}
default: {
return `npm install -g ${PACKAGE_NAME}@latest`;
}
}
};

export const updateCommand = new Command("update")
.description("Update the Memberstack CLI to the latest version")
.action(async () => {
const pm = detectPackageManager();

process.stderr.write(
`\n ${pc.bold("Current version:")} ${currentVersion}\n`
);
process.stderr.write(` ${pc.bold("Package manager:")} ${pm}\n\n`);

const command = getUpdateCommand(pm);
const spinner = yoctoSpinner({ text: `Running ${command}...` }).start();

try {
await execAsync(command);
spinner.stop();
printSuccess(
`Successfully updated ${DISPLAY_NAME}. Run "memberstack --version" to verify.`
);
} catch (error) {
spinner.stop();
printError(
error instanceof Error
? error.message
: `Failed to update via ${pm}. Try running: ${command}`
);
process.exitCode = 1;
}
});
4 changes: 4 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,11 @@ import { plansCommand } from "./commands/plans.js";
import { pricesCommand } from "./commands/prices.js";
import { providersCommand } from "./commands/providers.js";
import { recordsCommand } from "./commands/records.js";
import { resetCommand } from "./commands/reset.js";
import { skillsCommand } from "./commands/skills.js";
import { ssoCommand } from "./commands/sso.js";
import { tablesCommand } from "./commands/tables.js";
import { updateCommand } from "./commands/update.js";
import { usersCommand } from "./commands/users.js";
import { whoamiCommand } from "./commands/whoami.js";
import { program } from "./lib/program.js";
Expand Down Expand Up @@ -73,5 +75,7 @@ program.addCommand(usersCommand);
program.addCommand(providersCommand);
program.addCommand(skillsCommand);
program.addCommand(ssoCommand);
program.addCommand(resetCommand);
program.addCommand(updateCommand);

await program.parseAsync();
98 changes: 98 additions & 0 deletions tests/commands/reset.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import { describe, expect, it, vi } from "vitest";
import { runCommand } from "./helpers.js";

vi.mock("../../src/lib/program.js", () => ({
program: { opts: () => ({}) },
}));

const mockRm = vi.fn();
vi.mock("node:fs/promises", () => ({
rm: (...args: unknown[]) => mockRm(...args),
}));

const mockClearTokens = vi.fn();
vi.mock("../../src/lib/token-storage.js", () => ({
clearTokens: () => mockClearTokens(),
}));

let mockAnswer = "y";
vi.mock("node:readline", () => ({
createInterface: () => ({
question: (_msg: string, cb: (answer: string) => void) => {
cb(mockAnswer);
},
close: vi.fn(),
}),
}));

const { resetCommand } = await import("../../src/commands/reset.js");

describe("reset", () => {
it("skips confirmation with --force", async () => {
mockRm.mockResolvedValue(undefined);
mockClearTokens.mockResolvedValueOnce(undefined);

await runCommand(resetCommand, ["--force"]);

expect(mockRm).toHaveBeenCalledTimes(2);
expect(mockClearTokens).toHaveBeenCalled();
});

it("aborts when user answers no", async () => {
mockAnswer = "n";
mockRm.mockReset();
mockClearTokens.mockReset();

await runCommand(resetCommand, []);

expect(mockRm).not.toHaveBeenCalled();
expect(mockClearTokens).not.toHaveBeenCalled();
});

it("proceeds when user answers yes", async () => {
mockAnswer = "y";
mockRm.mockReset();
mockRm.mockResolvedValue(undefined);
mockClearTokens.mockReset();
mockClearTokens.mockResolvedValueOnce(undefined);

await runCommand(resetCommand, []);

expect(mockRm).toHaveBeenCalledTimes(2);
expect(mockClearTokens).toHaveBeenCalled();
});

it("aborts on empty answer (no default)", async () => {
mockAnswer = "";
mockRm.mockReset();
mockClearTokens.mockReset();

await runCommand(resetCommand, []);

expect(mockRm).not.toHaveBeenCalled();
expect(mockClearTokens).not.toHaveBeenCalled();
});

it("handles missing files gracefully", async () => {
mockRm.mockReset();
mockRm.mockRejectedValue(new Error("ENOENT"));
mockClearTokens.mockReset();
mockClearTokens.mockResolvedValueOnce(undefined);

await runCommand(resetCommand, ["--force"]);

expect(mockClearTokens).toHaveBeenCalled();
});

it("handles unexpected errors", async () => {
mockRm.mockReset();
mockRm.mockResolvedValue(undefined);
mockClearTokens.mockReset();
mockClearTokens.mockRejectedValueOnce(new Error("Disk error"));

const original = process.exitCode;
await runCommand(resetCommand, ["--force"]);
expect(process.exitCode).toBe(1);
process.exitCode = original;
});
});
Loading