@revstackhq/cli 0.0.0-dev-20260226054033

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,206 @@
1
+ /**
2
+ * @file commands/push.ts
3
+ * @description The core deployment command. Loads the local config, sends it
4
+ * to Revstack Cloud for diffing, presents the changes, and (upon confirmation)
5
+ * pushes the config to production.
6
+ */
7
+
8
+ import { Command } from "commander";
9
+ import chalk from "chalk";
10
+ import prompts from "prompts";
11
+ import ora from "ora";
12
+ import { getApiKey } from "@/utils/auth";
13
+ import { loadLocalConfig } from "@/utils/config-loader";
14
+
15
+ // ─── Types ───────────────────────────────────────────────────
16
+
17
+ interface DiffEntry {
18
+ action: "added" | "removed" | "updated";
19
+ entity: string;
20
+ id: string;
21
+ message: string;
22
+ }
23
+
24
+ interface DiffResponse {
25
+ diff: DiffEntry[];
26
+ canPush: boolean;
27
+ blockedReason?: string;
28
+ }
29
+
30
+ // ─── Helpers ─────────────────────────────────────────────────
31
+
32
+ const API_BASE = "https://app.revstack.dev";
33
+
34
+ const DIFF_ICONS: Record<DiffEntry["action"], string> = {
35
+ added: chalk.green(" + "),
36
+ removed: chalk.red(" − "),
37
+ updated: chalk.yellow(" ~ "),
38
+ };
39
+
40
+ const DIFF_COLORS: Record<DiffEntry["action"], (text: string) => string> = {
41
+ added: chalk.green,
42
+ removed: chalk.red,
43
+ updated: chalk.yellow,
44
+ };
45
+
46
+ function printDiff(diff: DiffEntry[]): void {
47
+ if (diff.length === 0) {
48
+ console.log(
49
+ chalk.dim("\n No changes detected. Your config is up to date.\n")
50
+ );
51
+ return;
52
+ }
53
+
54
+ console.log(chalk.bold("\n Changes:\n"));
55
+
56
+ for (const entry of diff) {
57
+ const icon = DIFF_ICONS[entry.action];
58
+ const color = DIFF_COLORS[entry.action];
59
+ const label = chalk.dim(`[${entry.entity}]`);
60
+ console.log(
61
+ `${icon}${color(entry.id)} ${label} ${chalk.white(entry.message)}`
62
+ );
63
+ }
64
+
65
+ console.log();
66
+ }
67
+
68
+ function requireAuth(): string {
69
+ const apiKey = getApiKey();
70
+
71
+ if (!apiKey) {
72
+ console.error(
73
+ "\n" +
74
+ chalk.red(" ✖ Not authenticated.\n") +
75
+ chalk.dim(" Run ") +
76
+ chalk.bold("revstack login") +
77
+ chalk.dim(" first.\n")
78
+ );
79
+ process.exit(1);
80
+ }
81
+
82
+ return apiKey;
83
+ }
84
+
85
+ // ─── Command ─────────────────────────────────────────────────
86
+
87
+ export const pushCommand = new Command("push")
88
+ .description("Push your local billing config to Revstack Cloud")
89
+ .option("-e, --env <environment>", "Target environment", "test")
90
+ .action(async (options: { env: string }) => {
91
+ const apiKey = requireAuth();
92
+ const config = await loadLocalConfig(process.cwd());
93
+
94
+ // ── Step 1: Compute diff ──────────────────────────────────
95
+
96
+ const spinner = ora({
97
+ text: "Calculating diff...",
98
+ prefixText: " ",
99
+ }).start();
100
+
101
+ let diffResponse: DiffResponse;
102
+
103
+ try {
104
+ const res = await fetch(`${API_BASE}/api/v1/cli/diff`, {
105
+ method: "POST",
106
+ headers: {
107
+ "Content-Type": "application/json",
108
+ Authorization: `Bearer ${apiKey}`,
109
+ },
110
+ body: JSON.stringify({ env: options.env, config }),
111
+ });
112
+
113
+ if (!res.ok) {
114
+ spinner.fail("Failed to calculate diff");
115
+ console.error(
116
+ chalk.red(`\n API returned ${res.status}: ${res.statusText}\n`)
117
+ );
118
+ process.exit(1);
119
+ }
120
+
121
+ diffResponse = (await res.json()) as DiffResponse;
122
+ spinner.succeed("Diff calculated");
123
+ } catch (error: unknown) {
124
+ spinner.fail("Failed to reach Revstack Cloud");
125
+ console.error(chalk.red(`\n ${(error as Error).message}\n`));
126
+ process.exit(1);
127
+ }
128
+
129
+ // ── Step 2: Present diff ──────────────────────────────────
130
+
131
+ printDiff(diffResponse.diff);
132
+
133
+ if (diffResponse.diff.length === 0) {
134
+ return;
135
+ }
136
+
137
+ // ── Step 3: Check if push is allowed ──────────────────────
138
+
139
+ if (!diffResponse.canPush) {
140
+ console.log(
141
+ chalk.red(" ✖ Push is blocked.\n") +
142
+ chalk.dim(
143
+ ` ${diffResponse.blockedReason ?? "The server rejected this configuration."}\n`
144
+ )
145
+ );
146
+ process.exit(1);
147
+ }
148
+
149
+ // ── Step 4: Confirm ───────────────────────────────────────
150
+
151
+ const envLabel =
152
+ options.env === "production"
153
+ ? chalk.red.bold(options.env)
154
+ : chalk.cyan.bold(options.env);
155
+
156
+ const { confirm } = await prompts({
157
+ type: "confirm",
158
+ name: "confirm",
159
+ message: `Apply these changes to ${envLabel}?`,
160
+ initial: false,
161
+ });
162
+
163
+ if (!confirm) {
164
+ console.log(chalk.dim("\n Push cancelled.\n"));
165
+ return;
166
+ }
167
+
168
+ // ── Step 5: Push ──────────────────────────────────────────
169
+
170
+ const pushSpinner = ora({
171
+ text: `Pushing to ${options.env}...`,
172
+ prefixText: " ",
173
+ }).start();
174
+
175
+ try {
176
+ const res = await fetch(`${API_BASE}/api/v1/cli/push`, {
177
+ method: "POST",
178
+ headers: {
179
+ "Content-Type": "application/json",
180
+ Authorization: `Bearer ${apiKey}`,
181
+ },
182
+ body: JSON.stringify({ env: options.env, config }),
183
+ });
184
+
185
+ if (!res.ok) {
186
+ pushSpinner.fail("Push failed");
187
+ console.error(
188
+ chalk.red(`\n API returned ${res.status}: ${res.statusText}\n`)
189
+ );
190
+ process.exit(1);
191
+ }
192
+
193
+ pushSpinner.succeed("Pushed successfully");
194
+ console.log(
195
+ "\n" +
196
+ chalk.green(" ✔ Config deployed to ") +
197
+ envLabel +
198
+ "\n" +
199
+ chalk.dim(" Changes are now live.\n")
200
+ );
201
+ } catch (error: unknown) {
202
+ pushSpinner.fail("Push failed");
203
+ console.error(chalk.red(`\n ${(error as Error).message}\n`));
204
+ process.exit(1);
205
+ }
206
+ });
@@ -0,0 +1,59 @@
1
+ /**
2
+ * @file utils/auth.ts
3
+ * @description Manages global Revstack credentials stored at ~/.revstack/credentials.json.
4
+ * Provides simple get/set helpers for the API key used by all authenticated commands.
5
+ */
6
+
7
+ import fs from "node:fs";
8
+ import path from "node:path";
9
+ import os from "node:os";
10
+
11
+ const CREDENTIALS_DIR = path.join(os.homedir(), ".revstack");
12
+ const CREDENTIALS_FILE = path.join(CREDENTIALS_DIR, "credentials.json");
13
+
14
+ interface Credentials {
15
+ apiKey: string;
16
+ }
17
+
18
+ /**
19
+ * Persist an API key to the global credentials file.
20
+ * Creates `~/.revstack/` if it doesn't exist.
21
+ */
22
+ export function setApiKey(key: string): void {
23
+ if (!fs.existsSync(CREDENTIALS_DIR)) {
24
+ fs.mkdirSync(CREDENTIALS_DIR, { recursive: true });
25
+ }
26
+
27
+ const credentials: Credentials = { apiKey: key };
28
+ fs.writeFileSync(
29
+ CREDENTIALS_FILE,
30
+ JSON.stringify(credentials, null, 2),
31
+ "utf-8"
32
+ );
33
+ }
34
+
35
+ /**
36
+ * Read the stored API key, or return `null` if none is configured.
37
+ */
38
+ export function getApiKey(): string | null {
39
+ if (!fs.existsSync(CREDENTIALS_FILE)) {
40
+ return null;
41
+ }
42
+
43
+ try {
44
+ const raw = fs.readFileSync(CREDENTIALS_FILE, "utf-8");
45
+ const credentials: Credentials = JSON.parse(raw);
46
+ return credentials.apiKey ?? null;
47
+ } catch {
48
+ return null;
49
+ }
50
+ }
51
+
52
+ /**
53
+ * Remove stored credentials. Used by `revstack logout`.
54
+ */
55
+ export function clearApiKey(): void {
56
+ if (fs.existsSync(CREDENTIALS_FILE)) {
57
+ fs.unlinkSync(CREDENTIALS_FILE);
58
+ }
59
+ }
@@ -0,0 +1,57 @@
1
+ /**
2
+ * @file utils/config-loader.ts
3
+ * @description Loads and evaluates the user's `revstack.config.ts` at runtime
4
+ * using jiti (just-in-time TypeScript compilation). Returns a sanitized,
5
+ * JSON-safe representation of the config for network transmission.
6
+ */
7
+
8
+ import { createJiti } from "jiti";
9
+ import path from "node:path";
10
+ import chalk from "chalk";
11
+
12
+ /**
13
+ * Load the `revstack.config.ts` from the given directory.
14
+ *
15
+ * @param cwd - The directory to search for `revstack.config.ts`.
16
+ * @returns The parsed and sanitized configuration object.
17
+ */
18
+ export async function loadLocalConfig(
19
+ cwd: string
20
+ ): Promise<Record<string, unknown>> {
21
+ const configPath = path.resolve(cwd, "revstack.config.ts");
22
+
23
+ try {
24
+ const jiti = createJiti(cwd);
25
+ const module = (await jiti.import(configPath)) as Record<string, unknown>;
26
+ const config = (module.default ?? module) as Record<string, unknown>;
27
+
28
+ // Sanitize: strip functions, class instances, and non-serializable data.
29
+ // This ensures we only send plain JSON to the Revstack Cloud API.
30
+ return JSON.parse(JSON.stringify(config));
31
+ } catch (error: unknown) {
32
+ const err = error as NodeJS.ErrnoException;
33
+
34
+ if (
35
+ err.code === "ERR_MODULE_NOT_FOUND" ||
36
+ err.code === "ENOENT" ||
37
+ err.code === "MODULE_NOT_FOUND"
38
+ ) {
39
+ console.error(
40
+ chalk.red(
41
+ "\n ✖ Could not find revstack.config.ts in the current directory.\n"
42
+ ) +
43
+ chalk.dim(" Run ") +
44
+ chalk.bold("revstack init") +
45
+ chalk.dim(" to create one.\n")
46
+ );
47
+ } else {
48
+ console.error(
49
+ chalk.red("\n ✖ Failed to parse revstack.config.ts\n") +
50
+ chalk.dim(" " + (err.message ?? String(error))) +
51
+ "\n"
52
+ );
53
+ }
54
+
55
+ process.exit(1);
56
+ }
57
+ }
@@ -0,0 +1,97 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2
+ import { Command } from "commander";
3
+
4
+ // ── Mock node:fs ─────────────────────────────────────────────
5
+ vi.mock("node:fs", () => ({
6
+ default: {
7
+ existsSync: vi.fn(),
8
+ writeFileSync: vi.fn(),
9
+ },
10
+ existsSync: vi.fn(),
11
+ writeFileSync: vi.fn(),
12
+ }));
13
+
14
+ // ── Mock chalk (no-op proxy) ─────────────────────────────────
15
+ vi.mock("chalk", () => {
16
+ const passthrough = (s: string) => s;
17
+ const handler: ProxyHandler<typeof passthrough> = {
18
+ get: () => new Proxy(passthrough, handler),
19
+ apply: (_t, _ctx, args: string[]) => args[0],
20
+ };
21
+ return { default: new Proxy(passthrough, handler) };
22
+ });
23
+
24
+ // ── Imports (after mocks) ────────────────────────────────────
25
+ import fs from "node:fs";
26
+ import { initCommand } from "../../src/commands/init";
27
+
28
+ const mockFs = vi.mocked(fs);
29
+
30
+ class ProcessExitError extends Error {
31
+ code: number;
32
+ constructor(code: number) {
33
+ super(`process.exit(${code})`);
34
+ this.code = code;
35
+ }
36
+ }
37
+
38
+ function createTestProgram(): Command {
39
+ const program = new Command();
40
+ program.exitOverride();
41
+ program.addCommand(initCommand);
42
+ return program;
43
+ }
44
+
45
+ let exitSpy: ReturnType<typeof vi.spyOn>;
46
+
47
+ beforeEach(() => {
48
+ vi.clearAllMocks();
49
+ exitSpy = vi
50
+ .spyOn(process, "exit")
51
+ .mockImplementation((code?: string | number | null | undefined) => {
52
+ throw new ProcessExitError(Number(code ?? 0));
53
+ });
54
+ });
55
+
56
+ afterEach(() => {
57
+ exitSpy.mockRestore();
58
+ });
59
+
60
+ // ── Tests ────────────────────────────────────────────────────
61
+
62
+ describe("init command", () => {
63
+ it("creates revstack.config.ts when it does not exist", async () => {
64
+ mockFs.existsSync.mockReturnValue(false);
65
+ const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {});
66
+
67
+ const program = createTestProgram();
68
+ await program.parseAsync(["node", "revstack", "init"], { from: "node" });
69
+
70
+ expect(mockFs.writeFileSync).toHaveBeenCalledWith(
71
+ expect.stringContaining("revstack.config.ts"),
72
+ expect.stringContaining("defineConfig"),
73
+ "utf-8"
74
+ );
75
+ expect(consoleSpy).toHaveBeenCalledWith(
76
+ expect.stringContaining("Created revstack.config.ts")
77
+ );
78
+
79
+ consoleSpy.mockRestore();
80
+ });
81
+
82
+ it("exits with code 1 when revstack.config.ts already exists", async () => {
83
+ mockFs.existsSync.mockReturnValue(true);
84
+ const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {});
85
+
86
+ const program = createTestProgram();
87
+
88
+ await expect(
89
+ program.parseAsync(["node", "revstack", "init"], { from: "node" })
90
+ ).rejects.toThrow(ProcessExitError);
91
+
92
+ expect(exitSpy).toHaveBeenCalledWith(1);
93
+ expect(mockFs.writeFileSync).not.toHaveBeenCalled();
94
+
95
+ consoleSpy.mockRestore();
96
+ });
97
+ });
@@ -0,0 +1,95 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2
+ import { Command } from "commander";
3
+
4
+ // ── Mock auth ────────────────────────────────────────────────
5
+ vi.mock("../../src/utils/auth", () => ({
6
+ setApiKey: vi.fn(),
7
+ }));
8
+
9
+ // ── Mock prompts ─────────────────────────────────────────────
10
+ vi.mock("prompts", () => ({
11
+ default: vi.fn(),
12
+ }));
13
+
14
+ // ── Mock chalk (no-op proxy) ─────────────────────────────────
15
+ vi.mock("chalk", () => {
16
+ const passthrough = (s: string) => s;
17
+ const handler: ProxyHandler<typeof passthrough> = {
18
+ get: () => new Proxy(passthrough, handler),
19
+ apply: (_t, _ctx, args: string[]) => args[0],
20
+ };
21
+ return { default: new Proxy(passthrough, handler) };
22
+ });
23
+
24
+ // ── Imports (after mocks) ────────────────────────────────────
25
+ import { setApiKey } from "../../src/utils/auth";
26
+ import prompts from "prompts";
27
+ import { loginCommand } from "../../src/commands/login";
28
+
29
+ const mockSetApiKey = vi.mocked(setApiKey);
30
+ const mockPrompts = vi.mocked(prompts);
31
+
32
+ class ProcessExitError extends Error {
33
+ code: number;
34
+ constructor(code: number) {
35
+ super(`process.exit(${code})`);
36
+ this.code = code;
37
+ }
38
+ }
39
+
40
+ function createTestProgram(): Command {
41
+ const program = new Command();
42
+ program.exitOverride();
43
+ program.addCommand(loginCommand);
44
+ return program;
45
+ }
46
+
47
+ let exitSpy: ReturnType<typeof vi.spyOn>;
48
+
49
+ beforeEach(() => {
50
+ vi.clearAllMocks();
51
+ exitSpy = vi
52
+ .spyOn(process, "exit")
53
+ .mockImplementation((code?: string | number | null | undefined) => {
54
+ throw new ProcessExitError(Number(code ?? 0));
55
+ });
56
+ });
57
+
58
+ afterEach(() => {
59
+ exitSpy.mockRestore();
60
+ });
61
+
62
+ // ── Tests ────────────────────────────────────────────────────
63
+
64
+ describe("login command", () => {
65
+ it("stores the API key when user provides a valid key", async () => {
66
+ mockPrompts.mockResolvedValue({ secretKey: "sk_test_mykey123" });
67
+ const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {});
68
+
69
+ const program = createTestProgram();
70
+ await program.parseAsync(["node", "revstack", "login"], { from: "node" });
71
+
72
+ expect(mockSetApiKey).toHaveBeenCalledWith("sk_test_mykey123");
73
+ expect(consoleSpy).toHaveBeenCalledWith(
74
+ expect.stringContaining("Authenticated successfully")
75
+ );
76
+
77
+ consoleSpy.mockRestore();
78
+ });
79
+
80
+ it("exits when user cancels the prompt", async () => {
81
+ mockPrompts.mockResolvedValue({}); // empty → no secretKey
82
+ const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {});
83
+
84
+ const program = createTestProgram();
85
+
86
+ await expect(
87
+ program.parseAsync(["node", "revstack", "login"], { from: "node" })
88
+ ).rejects.toThrow(ProcessExitError);
89
+
90
+ expect(exitSpy).toHaveBeenCalledWith(0);
91
+ expect(mockSetApiKey).not.toHaveBeenCalled();
92
+
93
+ consoleSpy.mockRestore();
94
+ });
95
+ });
@@ -0,0 +1,70 @@
1
+ import { describe, it, expect, vi, beforeEach } from "vitest";
2
+ import { Command } from "commander";
3
+
4
+ // ── Mock auth ────────────────────────────────────────────────
5
+ vi.mock("../../src/utils/auth", () => ({
6
+ getApiKey: vi.fn(),
7
+ clearApiKey: vi.fn(),
8
+ }));
9
+
10
+ // ── Mock chalk (no-op proxy) ─────────────────────────────────
11
+ vi.mock("chalk", () => {
12
+ const passthrough = (s: string) => s;
13
+ const handler: ProxyHandler<typeof passthrough> = {
14
+ get: () => new Proxy(passthrough, handler),
15
+ apply: (_t, _ctx, args: string[]) => args[0],
16
+ };
17
+ return { default: new Proxy(passthrough, handler) };
18
+ });
19
+
20
+ // ── Imports (after mocks) ────────────────────────────────────
21
+ import { getApiKey, clearApiKey } from "../../src/utils/auth";
22
+ import { logoutCommand } from "../../src/commands/logout";
23
+
24
+ const mockGetApiKey = vi.mocked(getApiKey);
25
+ const mockClearApiKey = vi.mocked(clearApiKey);
26
+
27
+ function createTestProgram(): Command {
28
+ const program = new Command();
29
+ program.exitOverride();
30
+ program.addCommand(logoutCommand);
31
+ return program;
32
+ }
33
+
34
+ beforeEach(() => {
35
+ vi.clearAllMocks();
36
+ });
37
+
38
+ // ── Tests ────────────────────────────────────────────────────
39
+
40
+ describe("logout command", () => {
41
+ it("prints 'not logged in' when no credentials exist", async () => {
42
+ mockGetApiKey.mockReturnValue(null);
43
+ const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {});
44
+
45
+ const program = createTestProgram();
46
+ await program.parseAsync(["node", "revstack", "logout"], { from: "node" });
47
+
48
+ expect(mockClearApiKey).not.toHaveBeenCalled();
49
+ expect(consoleSpy).toHaveBeenCalledWith(
50
+ expect.stringContaining("Not currently logged in")
51
+ );
52
+
53
+ consoleSpy.mockRestore();
54
+ });
55
+
56
+ it("clears credentials and prints success when logged in", async () => {
57
+ mockGetApiKey.mockReturnValue("sk_test_valid123");
58
+ const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {});
59
+
60
+ const program = createTestProgram();
61
+ await program.parseAsync(["node", "revstack", "logout"], { from: "node" });
62
+
63
+ expect(mockClearApiKey).toHaveBeenCalledOnce();
64
+ expect(consoleSpy).toHaveBeenCalledWith(
65
+ expect.stringContaining("Successfully logged out")
66
+ );
67
+
68
+ consoleSpy.mockRestore();
69
+ });
70
+ });