Skip to content

Commit 052b716

Browse files
authored
feat: add update and reset commands (#19)
Add `update` command that detects the user's package manager (npm, pnpm, yarn, bun) from the global install path and runs the appropriate global 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). Update README and ARCHITECTURE docs with new commands. Co-authored-by: Ben Sabic <bensabic@users.noreply.github.com>
1 parent 9a59bd2 commit 052b716

File tree

9 files changed

+370
-4
lines changed

9 files changed

+370
-4
lines changed

ARCHITECTURE.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,8 +25,10 @@ memberstack-cli/
2525
│ │ ├── records.ts # Record CRUD, query, find, import/export, bulk ops
2626
│ │ ├── skills.ts # Agent skill add/remove (wraps npx skills)
2727
│ │ ├── providers.ts # Auth provider management (list, configure, remove)
28+
│ │ ├── reset.ts # Delete local data files and clear authentication
2829
│ │ ├── sso.ts # SSO app management (list, create, update, delete)
2930
│ │ ├── tables.ts # Data table CRUD, describe
31+
│ │ ├── update.ts # Self-update CLI via detected package manager
3032
│ │ ├── users.ts # App user management (list, get, add, remove, update-role)
3133
│ │ └── whoami.ts # Show current app and user
3234
│ │
@@ -52,8 +54,10 @@ memberstack-cli/
5254
│ │ ├── records.test.ts
5355
│ │ ├── skills.test.ts
5456
│ │ ├── providers.test.ts
57+
│ │ ├── reset.test.ts
5558
│ │ ├── sso.test.ts
5659
│ │ ├── tables.test.ts
60+
│ │ ├── update.test.ts
5761
│ │ ├── users.test.ts
5862
│ │ └── whoami.test.ts
5963
│ │
@@ -103,7 +107,7 @@ Each file exports a Commander `Command` with subcommands. Most commands follow t
103107
4. Output results via `printTable()`, `printRecord()`, or `printSuccess()`
104108
5. Catch errors and set `process.exitCode = 1`
105109

106-
The `skills` command is an exception — it wraps `npx skills` (child process) to add/remove agent skills instead of calling the GraphQL API.
110+
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).
107111

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

README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,10 @@ memberstack skills add memberstack-cli
6767
| `custom-fields` | List, create, update, and delete custom fields |
6868
| `users` | List, get, add, remove, and update roles for app users |
6969
| `providers` | List, configure, and remove auth providers (e.g. Google) |
70+
| `sso` | List, create, update, and delete SSO apps |
7071
| `skills` | Add/remove agent skills for Claude Code and Codex |
72+
| `update` | Update the CLI to the latest version |
73+
| `reset` | Delete local data files and clear authentication |
7174

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

src/commands/auth.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -209,7 +209,7 @@ authCommand
209209
` ${pc.bold("Status:")} ${pc.yellow("Not logged in")}\n`
210210
);
211211
process.stderr.write(
212-
`\n Run ${pc.cyan("memberstack-cli auth login")} to authenticate.\n`
212+
`\n Run ${pc.cyan("memberstack auth login")} to authenticate.\n`
213213
);
214214
process.stderr.write("\n");
215215
return;

src/commands/reset.ts

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import { rm } from "node:fs/promises";
2+
import { resolve } from "node:path";
3+
import { createInterface } from "node:readline";
4+
import { Command } from "commander";
5+
import pc from "picocolors";
6+
import { clearTokens } from "../lib/token-storage.js";
7+
import { printError, printSuccess } from "../lib/utils.js";
8+
9+
const FILES_TO_DELETE = ["members.json", "members.csv"];
10+
11+
const confirm = (message: string): Promise<boolean> =>
12+
new Promise((resolve) => {
13+
const rl = createInterface({
14+
input: process.stdin,
15+
output: process.stderr,
16+
});
17+
rl.question(message, (answer) => {
18+
rl.close();
19+
const normalized = answer.trim().toLowerCase();
20+
resolve(normalized === "y" || normalized === "yes");
21+
});
22+
});
23+
24+
const tryDelete = async (filePath: string): Promise<boolean> => {
25+
try {
26+
await rm(filePath);
27+
return true;
28+
} catch {
29+
return false;
30+
}
31+
};
32+
33+
export const resetCommand = new Command("reset")
34+
.description("Delete local data files and clear authentication")
35+
.option("-f, --force", "Skip confirmation prompt")
36+
.action(async (opts: { force?: boolean }) => {
37+
if (!opts.force) {
38+
process.stderr.write("\n");
39+
process.stderr.write(` ${pc.bold("This will:")}\n`);
40+
process.stderr.write(
41+
` - Delete ${FILES_TO_DELETE.join(", ")} (if present)\n`
42+
);
43+
process.stderr.write(" - Clear stored authentication tokens\n");
44+
process.stderr.write("\n");
45+
46+
const proceed = await confirm(` ${pc.bold("Continue?")} (y/n) `);
47+
if (!proceed) {
48+
process.stderr.write("\n Aborted.\n\n");
49+
return;
50+
}
51+
process.stderr.write("\n");
52+
}
53+
54+
try {
55+
const results: string[] = [];
56+
57+
for (const file of FILES_TO_DELETE) {
58+
const fullPath = resolve(file);
59+
const deleted = await tryDelete(fullPath);
60+
if (deleted) {
61+
results.push(`Deleted ${file}`);
62+
}
63+
}
64+
65+
await clearTokens();
66+
results.push("Cleared authentication tokens");
67+
68+
for (const result of results) {
69+
printSuccess(` ${result}`);
70+
}
71+
72+
if (results.length === 1) {
73+
process.stderr.write(
74+
`\n ${pc.dim("No local data files found to delete.")}\n`
75+
);
76+
}
77+
78+
process.stderr.write("\n");
79+
} catch (error) {
80+
printError(
81+
error instanceof Error ? error.message : "An unknown error occurred"
82+
);
83+
process.exitCode = 1;
84+
}
85+
});

src/commands/update.ts

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import { exec } from "node:child_process";
2+
import { promisify } from "node:util";
3+
import { Command } from "commander";
4+
import pc from "picocolors";
5+
import yoctoSpinner from "yocto-spinner";
6+
import { printError, printSuccess } from "../lib/utils.js";
7+
8+
const execAsync = promisify(exec);
9+
10+
declare const __VERSION__: string | undefined;
11+
const currentVersion = typeof __VERSION__ !== "undefined" ? __VERSION__ : "dev";
12+
13+
const PACKAGE_NAME = "memberstack-cli";
14+
const DISPLAY_NAME = "Memberstack CLI";
15+
16+
type PackageManager = "bun" | "npm" | "pnpm" | "yarn";
17+
18+
const detectPackageManager = (): PackageManager => {
19+
const scriptPath = process.argv[1] ?? "";
20+
if (scriptPath.includes("/pnpm/") || scriptPath.includes("/.pnpm/")) {
21+
return "pnpm";
22+
}
23+
if (scriptPath.includes("/yarn/")) {
24+
return "yarn";
25+
}
26+
if (scriptPath.includes("/.bun/") || scriptPath.includes("/bun/")) {
27+
return "bun";
28+
}
29+
return "npm";
30+
};
31+
32+
const getUpdateCommand = (pm: PackageManager): string => {
33+
switch (pm) {
34+
case "bun": {
35+
return `bun install -g ${PACKAGE_NAME}@latest`;
36+
}
37+
case "pnpm": {
38+
return `pnpm add -g ${PACKAGE_NAME}@latest`;
39+
}
40+
case "yarn": {
41+
return `yarn global add ${PACKAGE_NAME}@latest`;
42+
}
43+
default: {
44+
return `npm install -g ${PACKAGE_NAME}@latest`;
45+
}
46+
}
47+
};
48+
49+
export const updateCommand = new Command("update")
50+
.description("Update the Memberstack CLI to the latest version")
51+
.action(async () => {
52+
const pm = detectPackageManager();
53+
54+
process.stderr.write(
55+
`\n ${pc.bold("Current version:")} ${currentVersion}\n`
56+
);
57+
process.stderr.write(` ${pc.bold("Package manager:")} ${pm}\n\n`);
58+
59+
const command = getUpdateCommand(pm);
60+
const spinner = yoctoSpinner({ text: `Running ${command}...` }).start();
61+
62+
try {
63+
await execAsync(command);
64+
spinner.stop();
65+
printSuccess(
66+
`Successfully updated ${DISPLAY_NAME}. Run "memberstack --version" to verify.`
67+
);
68+
} catch (error) {
69+
spinner.stop();
70+
printError(
71+
error instanceof Error
72+
? error.message
73+
: `Failed to update via ${pm}. Try running: ${command}`
74+
);
75+
process.exitCode = 1;
76+
}
77+
});

src/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,11 @@ import { plansCommand } from "./commands/plans.js";
1212
import { pricesCommand } from "./commands/prices.js";
1313
import { providersCommand } from "./commands/providers.js";
1414
import { recordsCommand } from "./commands/records.js";
15+
import { resetCommand } from "./commands/reset.js";
1516
import { skillsCommand } from "./commands/skills.js";
1617
import { ssoCommand } from "./commands/sso.js";
1718
import { tablesCommand } from "./commands/tables.js";
19+
import { updateCommand } from "./commands/update.js";
1820
import { usersCommand } from "./commands/users.js";
1921
import { whoamiCommand } from "./commands/whoami.js";
2022
import { program } from "./lib/program.js";
@@ -73,5 +75,7 @@ program.addCommand(usersCommand);
7375
program.addCommand(providersCommand);
7476
program.addCommand(skillsCommand);
7577
program.addCommand(ssoCommand);
78+
program.addCommand(resetCommand);
79+
program.addCommand(updateCommand);
7680

7781
await program.parseAsync();

tests/commands/reset.test.ts

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
import { describe, expect, it, vi } from "vitest";
2+
import { runCommand } from "./helpers.js";
3+
4+
vi.mock("../../src/lib/program.js", () => ({
5+
program: { opts: () => ({}) },
6+
}));
7+
8+
const mockRm = vi.fn();
9+
vi.mock("node:fs/promises", () => ({
10+
rm: (...args: unknown[]) => mockRm(...args),
11+
}));
12+
13+
const mockClearTokens = vi.fn();
14+
vi.mock("../../src/lib/token-storage.js", () => ({
15+
clearTokens: () => mockClearTokens(),
16+
}));
17+
18+
let mockAnswer = "y";
19+
vi.mock("node:readline", () => ({
20+
createInterface: () => ({
21+
question: (_msg: string, cb: (answer: string) => void) => {
22+
cb(mockAnswer);
23+
},
24+
close: vi.fn(),
25+
}),
26+
}));
27+
28+
const { resetCommand } = await import("../../src/commands/reset.js");
29+
30+
describe("reset", () => {
31+
it("skips confirmation with --force", async () => {
32+
mockRm.mockResolvedValue(undefined);
33+
mockClearTokens.mockResolvedValueOnce(undefined);
34+
35+
await runCommand(resetCommand, ["--force"]);
36+
37+
expect(mockRm).toHaveBeenCalledTimes(2);
38+
expect(mockClearTokens).toHaveBeenCalled();
39+
});
40+
41+
it("aborts when user answers no", async () => {
42+
mockAnswer = "n";
43+
mockRm.mockReset();
44+
mockClearTokens.mockReset();
45+
46+
await runCommand(resetCommand, []);
47+
48+
expect(mockRm).not.toHaveBeenCalled();
49+
expect(mockClearTokens).not.toHaveBeenCalled();
50+
});
51+
52+
it("proceeds when user answers yes", async () => {
53+
mockAnswer = "y";
54+
mockRm.mockReset();
55+
mockRm.mockResolvedValue(undefined);
56+
mockClearTokens.mockReset();
57+
mockClearTokens.mockResolvedValueOnce(undefined);
58+
59+
await runCommand(resetCommand, []);
60+
61+
expect(mockRm).toHaveBeenCalledTimes(2);
62+
expect(mockClearTokens).toHaveBeenCalled();
63+
});
64+
65+
it("aborts on empty answer (no default)", async () => {
66+
mockAnswer = "";
67+
mockRm.mockReset();
68+
mockClearTokens.mockReset();
69+
70+
await runCommand(resetCommand, []);
71+
72+
expect(mockRm).not.toHaveBeenCalled();
73+
expect(mockClearTokens).not.toHaveBeenCalled();
74+
});
75+
76+
it("handles missing files gracefully", async () => {
77+
mockRm.mockReset();
78+
mockRm.mockRejectedValue(new Error("ENOENT"));
79+
mockClearTokens.mockReset();
80+
mockClearTokens.mockResolvedValueOnce(undefined);
81+
82+
await runCommand(resetCommand, ["--force"]);
83+
84+
expect(mockClearTokens).toHaveBeenCalled();
85+
});
86+
87+
it("handles unexpected errors", async () => {
88+
mockRm.mockReset();
89+
mockRm.mockResolvedValue(undefined);
90+
mockClearTokens.mockReset();
91+
mockClearTokens.mockRejectedValueOnce(new Error("Disk error"));
92+
93+
const original = process.exitCode;
94+
await runCommand(resetCommand, ["--force"]);
95+
expect(process.exitCode).toBe(1);
96+
process.exitCode = original;
97+
});
98+
});

0 commit comments

Comments
 (0)