From 737a984572f2ae8b2dc46e0bdf3141e82402280c Mon Sep 17 00:00:00 2001 From: Ben Sabic Date: Sun, 22 Feb 2026 22:25:51 +1100 Subject: [PATCH] feat: add update and reset commands Add `update` command that detects the package manager (npm, pnpm, yarn, bun) from the global install path and runs the appropriate update command. Add `reset` command that deletes members.json/members.csv in the current directory and clears stored auth tokens, with y/n confirmation prompt (skippable with --force). --- ARCHITECTURE.md | 6 ++- README.md | 3 ++ src/commands/auth.ts | 2 +- src/commands/reset.ts | 85 ++++++++++++++++++++++++++++++ src/commands/update.ts | 77 +++++++++++++++++++++++++++ src/index.ts | 4 ++ tests/commands/reset.test.ts | 98 +++++++++++++++++++++++++++++++++++ tests/commands/update.test.ts | 89 +++++++++++++++++++++++++++++++ tests/core/index.test.ts | 10 +++- 9 files changed, 370 insertions(+), 4 deletions(-) create mode 100644 src/commands/reset.ts create mode 100644 src/commands/update.ts create mode 100644 tests/commands/reset.test.ts create mode 100644 tests/commands/update.test.ts diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 9435305..2d116ea 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -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 │ │ @@ -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 │ │ @@ -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]`. diff --git a/README.md b/README.md index 496dad6..79b47d5 100644 --- a/README.md +++ b/README.md @@ -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). diff --git a/src/commands/auth.ts b/src/commands/auth.ts index 77eb737..7247d0d 100644 --- a/src/commands/auth.ts +++ b/src/commands/auth.ts @@ -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; diff --git a/src/commands/reset.ts b/src/commands/reset.ts new file mode 100644 index 0000000..49009aa --- /dev/null +++ b/src/commands/reset.ts @@ -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 => + 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 => { + 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; + } + }); diff --git a/src/commands/update.ts b/src/commands/update.ts new file mode 100644 index 0000000..f1f666e --- /dev/null +++ b/src/commands/update.ts @@ -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; + } + }); diff --git a/src/index.ts b/src/index.ts index 9a29430..89612d0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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"; @@ -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(); diff --git a/tests/commands/reset.test.ts b/tests/commands/reset.test.ts new file mode 100644 index 0000000..26b2e43 --- /dev/null +++ b/tests/commands/reset.test.ts @@ -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; + }); +}); diff --git a/tests/commands/update.test.ts b/tests/commands/update.test.ts new file mode 100644 index 0000000..f159e95 --- /dev/null +++ b/tests/commands/update.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it, vi } from "vitest"; +import { createMockSpinner, runCommand } from "./helpers.js"; + +vi.mock("yocto-spinner", () => ({ default: () => createMockSpinner() })); +vi.mock("../../src/lib/program.js", () => ({ + program: { opts: () => ({}) }, +})); + +const execAsync = vi.fn(); +vi.mock("node:child_process", () => ({ + exec: (...args: unknown[]) => { + const cb = args.at(-1) as ( + err: Error | null, + result: { stdout: string; stderr: string } + ) => void; + const promise = execAsync(args[0]); + promise + .then(() => cb(null, { stdout: "", stderr: "" })) + .catch((err: Error) => cb(err, { stdout: "", stderr: "" })); + }, +})); + +const { updateCommand } = await import("../../src/commands/update.js"); + +describe("update", () => { + it("runs npm install -g by default", async () => { + const originalArgv1 = process.argv[1]; + process.argv[1] = "/usr/local/lib/node_modules/.bin/memberstack"; + execAsync.mockResolvedValueOnce({ stdout: "", stderr: "" }); + + await runCommand(updateCommand, []); + + expect(execAsync).toHaveBeenCalledWith( + expect.stringContaining("npm install -g memberstack-cli@latest") + ); + process.argv[1] = originalArgv1; + }); + + it("detects pnpm from script path", async () => { + const originalArgv1 = process.argv[1]; + process.argv[1] = + "/home/user/.local/share/pnpm/global/5/node_modules/.bin/memberstack"; + execAsync.mockResolvedValueOnce({ stdout: "", stderr: "" }); + + await runCommand(updateCommand, []); + + expect(execAsync).toHaveBeenCalledWith( + expect.stringContaining("pnpm add -g memberstack-cli@latest") + ); + process.argv[1] = originalArgv1; + }); + + it("detects yarn from script path", async () => { + const originalArgv1 = process.argv[1]; + process.argv[1] = + "/home/user/.config/yarn/global/node_modules/.bin/memberstack"; + execAsync.mockResolvedValueOnce({ stdout: "", stderr: "" }); + + await runCommand(updateCommand, []); + + expect(execAsync).toHaveBeenCalledWith( + expect.stringContaining("yarn global add memberstack-cli@latest") + ); + process.argv[1] = originalArgv1; + }); + + it("detects bun from script path", async () => { + const originalArgv1 = process.argv[1]; + process.argv[1] = + "/home/user/.bun/install/global/node_modules/.bin/memberstack"; + execAsync.mockResolvedValueOnce({ stdout: "", stderr: "" }); + + await runCommand(updateCommand, []); + + expect(execAsync).toHaveBeenCalledWith( + expect.stringContaining("bun install -g memberstack-cli@latest") + ); + process.argv[1] = originalArgv1; + }); + + it("handles update failure", async () => { + execAsync.mockRejectedValueOnce(new Error("Permission denied")); + + const original = process.exitCode; + await runCommand(updateCommand, []); + expect(process.exitCode).toBe(1); + process.exitCode = original; + }); +}); diff --git a/tests/core/index.test.ts b/tests/core/index.test.ts index 37c4413..510b3dc 100644 --- a/tests/core/index.test.ts +++ b/tests/core/index.test.ts @@ -41,10 +41,16 @@ vi.mock("../../src/commands/skills.js", () => ({ })); vi.mock("../../src/commands/sso.js", () => ({ ssoCommand: "sso" })); vi.mock("../../src/commands/tables.js", () => ({ tablesCommand: "tables" })); +vi.mock("../../src/commands/update.js", () => ({ + updateCommand: "update", +})); vi.mock("../../src/commands/users.js", () => ({ usersCommand: "users" })); vi.mock("../../src/commands/whoami.js", () => ({ whoamiCommand: "whoami", })); +vi.mock("../../src/commands/reset.js", () => ({ + resetCommand: "reset", +})); describe("index", () => { let stderrSpy: ReturnType; @@ -116,12 +122,12 @@ describe("index", () => { expect(process.env.NO_COLOR).toBe("1"); }); - it("registers all 14 commands", async () => { + it("registers all 16 commands", async () => { process.argv = ["node", "memberstack"]; await import("../../src/index.js"); - expect(mockAddCommand).toHaveBeenCalledTimes(14); + expect(mockAddCommand).toHaveBeenCalledTimes(16); }); it("calls parseAsync", async () => {