@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.
- package/.turbo/turbo-build.log +12 -0
- package/CHANGELOG.md +7 -0
- package/LICENSE +21 -0
- package/README.md +175 -0
- package/dist/cli.js +536 -0
- package/dist/cli.js.map +1 -0
- package/package.json +36 -0
- package/src/cli.ts +32 -0
- package/src/commands/init.ts +190 -0
- package/src/commands/login.ts +39 -0
- package/src/commands/logout.ts +25 -0
- package/src/commands/pull.ts +266 -0
- package/src/commands/push.ts +206 -0
- package/src/utils/auth.ts +59 -0
- package/src/utils/config-loader.ts +57 -0
- package/tests/integration/init.test.ts +97 -0
- package/tests/integration/login.test.ts +95 -0
- package/tests/integration/logout.test.ts +70 -0
- package/tests/integration/pull.test.ts +216 -0
- package/tests/integration/push.test.ts +204 -0
- package/tests/unit/auth.test.ts +112 -0
- package/tests/unit/config-loader.test.ts +94 -0
- package/tsconfig.json +21 -0
- package/tsconfig.test.json +12 -0
- package/tsup.config.ts +16 -0
- package/vitest.config.ts +15 -0
package/src/cli.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file cli.ts
|
|
3
|
+
* @description Entry point for the Revstack CLI.
|
|
4
|
+
* Registers all commands and parses process.argv.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { Command } from "commander";
|
|
8
|
+
import { createRequire } from "node:module";
|
|
9
|
+
|
|
10
|
+
import { loginCommand } from "@/commands/login.js";
|
|
11
|
+
import { logoutCommand } from "@/commands/logout.js";
|
|
12
|
+
import { initCommand } from "@/commands/init.js";
|
|
13
|
+
import { pushCommand } from "@/commands/push.js";
|
|
14
|
+
import { pullCommand } from "@/commands/pull.js";
|
|
15
|
+
|
|
16
|
+
const require = createRequire(import.meta.url);
|
|
17
|
+
const packageJson = require("../package.json") as { version: string };
|
|
18
|
+
|
|
19
|
+
const program = new Command();
|
|
20
|
+
|
|
21
|
+
program
|
|
22
|
+
.name("revstack")
|
|
23
|
+
.description("The official CLI for Revstack — Billing as Code")
|
|
24
|
+
.version(packageJson.version);
|
|
25
|
+
|
|
26
|
+
program.addCommand(loginCommand);
|
|
27
|
+
program.addCommand(logoutCommand);
|
|
28
|
+
program.addCommand(initCommand);
|
|
29
|
+
program.addCommand(pushCommand);
|
|
30
|
+
program.addCommand(pullCommand);
|
|
31
|
+
|
|
32
|
+
program.parse(process.argv);
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file commands/init.ts
|
|
3
|
+
* @description Scaffolds a new `revstack.config.ts` in the current directory.
|
|
4
|
+
* Generates a starter config with the immutable Default Guest Plan and
|
|
5
|
+
* a sample Pro plan using type-safe helpers from @revstackhq/core.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { Command } from "commander";
|
|
9
|
+
import chalk from "chalk";
|
|
10
|
+
import fs from "node:fs";
|
|
11
|
+
import path from "node:path";
|
|
12
|
+
import { spawnSync } from "node:child_process";
|
|
13
|
+
import ora from "ora";
|
|
14
|
+
|
|
15
|
+
const STARTER_CONFIG = `import { defineConfig, definePlan, defineFeature } from "@revstackhq/core";
|
|
16
|
+
|
|
17
|
+
// ─── Features ────────────────────────────────────────────────
|
|
18
|
+
|
|
19
|
+
const features = {
|
|
20
|
+
seats: defineFeature({
|
|
21
|
+
name: "Seats",
|
|
22
|
+
type: "static",
|
|
23
|
+
unit_type: "count",
|
|
24
|
+
}),
|
|
25
|
+
ai_tokens: defineFeature({
|
|
26
|
+
name: "AI Tokens",
|
|
27
|
+
type: "metered",
|
|
28
|
+
unit_type: "count",
|
|
29
|
+
}),
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
// ─── Plans ───────────────────────────────────────────────────
|
|
33
|
+
|
|
34
|
+
export default defineConfig({
|
|
35
|
+
features,
|
|
36
|
+
plans: {
|
|
37
|
+
// DO NOT DELETE: Automatically created default plan for guests.
|
|
38
|
+
default: definePlan<typeof features>({
|
|
39
|
+
name: "Default",
|
|
40
|
+
description: "Automatically created default plan for guests.",
|
|
41
|
+
is_default: true,
|
|
42
|
+
is_public: false,
|
|
43
|
+
type: "free",
|
|
44
|
+
features: {},
|
|
45
|
+
}),
|
|
46
|
+
pro: definePlan<typeof features>({
|
|
47
|
+
name: "Pro",
|
|
48
|
+
description: "For professional teams.",
|
|
49
|
+
is_default: false,
|
|
50
|
+
is_public: true,
|
|
51
|
+
type: "paid",
|
|
52
|
+
prices: [
|
|
53
|
+
{
|
|
54
|
+
amount: 2900,
|
|
55
|
+
currency: "USD",
|
|
56
|
+
billing_interval: "monthly",
|
|
57
|
+
trial_period_days: 14,
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
amount: 29000,
|
|
61
|
+
currency: "USD",
|
|
62
|
+
billing_interval: "yearly",
|
|
63
|
+
trial_period_days: 14,
|
|
64
|
+
}
|
|
65
|
+
],
|
|
66
|
+
features: {
|
|
67
|
+
seats: { value_limit: 5, is_hard_limit: true },
|
|
68
|
+
ai_tokens: { value_limit: 1000, reset_period: "monthly" },
|
|
69
|
+
},
|
|
70
|
+
}),
|
|
71
|
+
},
|
|
72
|
+
});
|
|
73
|
+
`;
|
|
74
|
+
|
|
75
|
+
export const initCommand = new Command("init")
|
|
76
|
+
.description("Scaffold a new revstack.config.ts in the current directory")
|
|
77
|
+
.action(async () => {
|
|
78
|
+
const cwd = process.cwd();
|
|
79
|
+
const configPath = path.resolve(cwd, "revstack.config.ts");
|
|
80
|
+
|
|
81
|
+
if (fs.existsSync(configPath)) {
|
|
82
|
+
console.log(
|
|
83
|
+
"\n" +
|
|
84
|
+
chalk.yellow(" ⚠ revstack.config.ts already exists.\n") +
|
|
85
|
+
chalk.dim(" Delete it first if you want to start fresh.\n"),
|
|
86
|
+
);
|
|
87
|
+
process.exit(1);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Step 1: Create revstack.config.ts
|
|
91
|
+
fs.writeFileSync(configPath, STARTER_CONFIG, "utf-8");
|
|
92
|
+
|
|
93
|
+
// Step 2: Detect package manager & verify package.json
|
|
94
|
+
let packageManager = "npm";
|
|
95
|
+
if (fs.existsSync(path.resolve(cwd, "pnpm-lock.yaml"))) {
|
|
96
|
+
packageManager = "pnpm";
|
|
97
|
+
} else if (fs.existsSync(path.resolve(cwd, "yarn.lock"))) {
|
|
98
|
+
packageManager = "yarn";
|
|
99
|
+
} else if (fs.existsSync(path.resolve(cwd, "package-lock.json"))) {
|
|
100
|
+
packageManager = "npm";
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const packageJsonPath = path.resolve(cwd, "package.json");
|
|
104
|
+
if (!fs.existsSync(packageJsonPath)) {
|
|
105
|
+
// Create a default package.json if it doesn't exist
|
|
106
|
+
try {
|
|
107
|
+
spawnSync("npm", ["init", "-y"], { cwd, stdio: "ignore", shell: true });
|
|
108
|
+
} catch (err) {
|
|
109
|
+
// Ignore initialization errors; the install command may still work or provide a better error.
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Step 3: Install @revstackhq/core
|
|
114
|
+
const spinner = ora("Installing @revstackhq/core...").start();
|
|
115
|
+
let installFailed = false;
|
|
116
|
+
|
|
117
|
+
try {
|
|
118
|
+
const installArgs =
|
|
119
|
+
packageManager === "yarn"
|
|
120
|
+
? ["add", "@revstackhq/core"]
|
|
121
|
+
: packageManager === "pnpm"
|
|
122
|
+
? ["add", "@revstackhq/core"]
|
|
123
|
+
: ["install", "@revstackhq/core"];
|
|
124
|
+
|
|
125
|
+
let result = spawnSync(packageManager, installArgs, { cwd, shell: true });
|
|
126
|
+
if (result.error || result.status !== 0) {
|
|
127
|
+
if (packageManager === "pnpm") {
|
|
128
|
+
result = spawnSync("pnpm", ["add", "-w", "@revstackhq/core"], {
|
|
129
|
+
cwd,
|
|
130
|
+
shell: true,
|
|
131
|
+
});
|
|
132
|
+
} else if (packageManager === "yarn") {
|
|
133
|
+
result = spawnSync("yarn", ["add", "-W", "@revstackhq/core"], {
|
|
134
|
+
cwd,
|
|
135
|
+
shell: true,
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (result.error || result.status !== 0) {
|
|
141
|
+
throw new Error(
|
|
142
|
+
"Install failed: " +
|
|
143
|
+
(result.stderr
|
|
144
|
+
? result.stderr.toString()
|
|
145
|
+
: result.stdout
|
|
146
|
+
? result.stdout.toString()
|
|
147
|
+
: "Unknown error"),
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
spinner.succeed("Dependencies installed");
|
|
151
|
+
} catch (err: any) {
|
|
152
|
+
installFailed = true;
|
|
153
|
+
spinner.fail(
|
|
154
|
+
"Failed to install @revstackhq/core automatically (" +
|
|
155
|
+
packageManager +
|
|
156
|
+
"). Reason: " +
|
|
157
|
+
err.message,
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Step 4: Final Success Message
|
|
162
|
+
console.log(
|
|
163
|
+
"\\n" +
|
|
164
|
+
chalk.green(" ✔ Created revstack.config.ts\\n") +
|
|
165
|
+
"\\n" +
|
|
166
|
+
chalk.dim(" Includes the ") +
|
|
167
|
+
chalk.white("Default Guest Plan") +
|
|
168
|
+
chalk.dim(" (required by Revstack).\\n") +
|
|
169
|
+
"\\n" +
|
|
170
|
+
chalk.dim(" Next steps:\\n") +
|
|
171
|
+
(installFailed
|
|
172
|
+
? chalk.dim(" 0. ") +
|
|
173
|
+
chalk.white(
|
|
174
|
+
"Run " +
|
|
175
|
+
chalk.bold(packageManager + " install @revstackhq/core") +
|
|
176
|
+
" manually\\n",
|
|
177
|
+
)
|
|
178
|
+
: "") +
|
|
179
|
+
chalk.dim(" 1. ") +
|
|
180
|
+
chalk.white("Edit the config to match your billing model\\n") +
|
|
181
|
+
chalk.dim(" 2. ") +
|
|
182
|
+
chalk.white("Run ") +
|
|
183
|
+
chalk.bold("revstack login") +
|
|
184
|
+
chalk.white(" to authenticate\\n") +
|
|
185
|
+
chalk.dim(" 3. ") +
|
|
186
|
+
chalk.white("Run ") +
|
|
187
|
+
chalk.bold("revstack push") +
|
|
188
|
+
chalk.white(" to deploy\\n"),
|
|
189
|
+
);
|
|
190
|
+
});
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file commands/login.ts
|
|
3
|
+
* @description Interactive authentication flow. Prompts the user for their
|
|
4
|
+
* Revstack Secret Key and stores it globally for subsequent CLI commands.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { Command } from "commander";
|
|
8
|
+
import chalk from "chalk";
|
|
9
|
+
import prompts from "prompts";
|
|
10
|
+
import { setApiKey } from "@/utils/auth.js";
|
|
11
|
+
|
|
12
|
+
export const loginCommand = new Command("login")
|
|
13
|
+
.description("Authenticate with your Revstack Secret Key")
|
|
14
|
+
.action(async () => {
|
|
15
|
+
console.log(
|
|
16
|
+
"\n" + chalk.bold(" Revstack ") + chalk.dim("— Authentication\n")
|
|
17
|
+
);
|
|
18
|
+
|
|
19
|
+
const response = await prompts({
|
|
20
|
+
type: "password",
|
|
21
|
+
name: "secretKey",
|
|
22
|
+
message: "Enter your Revstack Secret Key",
|
|
23
|
+
validate: (value: string) =>
|
|
24
|
+
value.startsWith("sk_") ? true : "Secret key must start with sk_",
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
if (!response.secretKey) {
|
|
28
|
+
console.log(chalk.dim("\n Login cancelled.\n"));
|
|
29
|
+
process.exit(0);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
setApiKey(response.secretKey);
|
|
33
|
+
|
|
34
|
+
console.log(
|
|
35
|
+
"\n" +
|
|
36
|
+
chalk.green(" ✔ Authenticated successfully!\n") +
|
|
37
|
+
chalk.dim(" Credentials saved to ~/.revstack/credentials.json\n")
|
|
38
|
+
);
|
|
39
|
+
});
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file commands/logout.ts
|
|
3
|
+
* @description Clears stored Revstack credentials.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { Command } from "commander";
|
|
7
|
+
import chalk from "chalk";
|
|
8
|
+
import { clearApiKey, getApiKey } from "@/utils/auth.js";
|
|
9
|
+
|
|
10
|
+
export const logoutCommand = new Command("logout")
|
|
11
|
+
.description("Clear stored Revstack credentials")
|
|
12
|
+
.action(() => {
|
|
13
|
+
if (!getApiKey()) {
|
|
14
|
+
console.log(chalk.dim("\n Not currently logged in.\n"));
|
|
15
|
+
return;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
clearApiKey();
|
|
19
|
+
|
|
20
|
+
console.log(
|
|
21
|
+
"\n" +
|
|
22
|
+
chalk.green(" ✔ Successfully logged out.\n") +
|
|
23
|
+
chalk.dim(" Credentials removed from ~/.revstack/credentials.json\n")
|
|
24
|
+
);
|
|
25
|
+
});
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file commands/pull.ts
|
|
3
|
+
* @description Fetches the current billing configuration from Revstack Cloud
|
|
4
|
+
* and writes it back to the local `revstack.config.ts` file, overwriting
|
|
5
|
+
* the existing config after user confirmation.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { Command } from "commander";
|
|
9
|
+
import chalk from "chalk";
|
|
10
|
+
import ora from "ora";
|
|
11
|
+
import prompts from "prompts";
|
|
12
|
+
import fs from "node:fs";
|
|
13
|
+
import path from "node:path";
|
|
14
|
+
import { getApiKey } from "@/utils/auth";
|
|
15
|
+
|
|
16
|
+
// ─── Types ───────────────────────────────────────────────────
|
|
17
|
+
|
|
18
|
+
interface RemoteFeature {
|
|
19
|
+
name: string;
|
|
20
|
+
type: string;
|
|
21
|
+
unit_type: string;
|
|
22
|
+
description?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
interface RemotePrice {
|
|
26
|
+
amount: number;
|
|
27
|
+
currency: string;
|
|
28
|
+
billing_interval: string;
|
|
29
|
+
trial_period_days?: number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
interface RemotePlanFeature {
|
|
33
|
+
value_limit?: number;
|
|
34
|
+
value_bool?: boolean;
|
|
35
|
+
value_text?: string;
|
|
36
|
+
is_hard_limit?: boolean;
|
|
37
|
+
reset_period?: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
interface RemotePlan {
|
|
41
|
+
name: string;
|
|
42
|
+
description?: string;
|
|
43
|
+
is_default: boolean;
|
|
44
|
+
is_public: boolean;
|
|
45
|
+
type: string;
|
|
46
|
+
prices?: RemotePrice[];
|
|
47
|
+
features: Record<string, RemotePlanFeature>;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
interface RemoteConfig {
|
|
51
|
+
features: Record<string, RemoteFeature>;
|
|
52
|
+
plans: Record<string, RemotePlan>;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// ─── Code Generator ──────────────────────────────────────────
|
|
56
|
+
|
|
57
|
+
function indent(text: string, spaces: number): string {
|
|
58
|
+
const pad = " ".repeat(spaces);
|
|
59
|
+
return text
|
|
60
|
+
.split("\n")
|
|
61
|
+
.map((line) => (line.trim() ? pad + line : line))
|
|
62
|
+
.join("\n");
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function serializeObject(
|
|
66
|
+
obj: Record<string, unknown>,
|
|
67
|
+
depth: number = 0
|
|
68
|
+
): string {
|
|
69
|
+
const entries = Object.entries(obj);
|
|
70
|
+
if (entries.length === 0) return "{}";
|
|
71
|
+
|
|
72
|
+
const pad = " ".repeat(depth + 1);
|
|
73
|
+
const closePad = " ".repeat(depth);
|
|
74
|
+
|
|
75
|
+
const lines = entries
|
|
76
|
+
.map(([key, value]) => {
|
|
77
|
+
if (value === undefined) return null;
|
|
78
|
+
|
|
79
|
+
const formattedValue =
|
|
80
|
+
typeof value === "string"
|
|
81
|
+
? `"${value}"`
|
|
82
|
+
: typeof value === "number" || typeof value === "boolean"
|
|
83
|
+
? String(value)
|
|
84
|
+
: Array.isArray(value)
|
|
85
|
+
? serializeArray(value, depth + 1)
|
|
86
|
+
: typeof value === "object" && value !== null
|
|
87
|
+
? serializeObject(value as Record<string, unknown>, depth + 1)
|
|
88
|
+
: String(value);
|
|
89
|
+
|
|
90
|
+
return `${pad}${key}: ${formattedValue},`;
|
|
91
|
+
})
|
|
92
|
+
.filter(Boolean);
|
|
93
|
+
|
|
94
|
+
return `{\n${lines.join("\n")}\n${closePad}}`;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function serializeArray(arr: unknown[], depth: number): string {
|
|
98
|
+
if (arr.length === 0) return "[]";
|
|
99
|
+
|
|
100
|
+
const pad = " ".repeat(depth + 1);
|
|
101
|
+
const closePad = " ".repeat(depth);
|
|
102
|
+
|
|
103
|
+
const items = arr.map((item) => {
|
|
104
|
+
if (typeof item === "object" && item !== null) {
|
|
105
|
+
return `${pad}${serializeObject(item as Record<string, unknown>, depth + 1)},`;
|
|
106
|
+
}
|
|
107
|
+
return `${pad}${JSON.stringify(item)},`;
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
return `[\n${items.join("\n")}\n${closePad}]`;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function generateConfigSource(config: RemoteConfig): string {
|
|
114
|
+
// ── Features ─────────────────────────────────────────────
|
|
115
|
+
const featureEntries = Object.entries(config.features).map(([slug, f]) => {
|
|
116
|
+
const props: Record<string, unknown> = {
|
|
117
|
+
name: f.name,
|
|
118
|
+
type: f.type,
|
|
119
|
+
unit_type: f.unit_type,
|
|
120
|
+
};
|
|
121
|
+
if (f.description) props.description = f.description;
|
|
122
|
+
|
|
123
|
+
return ` ${slug}: defineFeature(${serializeObject(props, 2)}),`;
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
// ── Plans ────────────────────────────────────────────────
|
|
127
|
+
const planEntries = Object.entries(config.plans).map(([slug, plan]) => {
|
|
128
|
+
const props: Record<string, unknown> = {
|
|
129
|
+
name: plan.name,
|
|
130
|
+
};
|
|
131
|
+
if (plan.description) props.description = plan.description;
|
|
132
|
+
props.is_default = plan.is_default;
|
|
133
|
+
props.is_public = plan.is_public;
|
|
134
|
+
props.type = plan.type;
|
|
135
|
+
|
|
136
|
+
if (plan.prices && plan.prices.length > 0) {
|
|
137
|
+
props.prices = plan.prices;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
props.features = plan.features;
|
|
141
|
+
|
|
142
|
+
const comment = plan.is_default
|
|
143
|
+
? ` // DO NOT DELETE: Automatically created default plan for guests.\n`
|
|
144
|
+
: "";
|
|
145
|
+
|
|
146
|
+
return `${comment} ${slug}: definePlan<typeof features>(${serializeObject(props, 3)}),`;
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
return `import { defineConfig, definePlan, defineFeature } from "@revstackhq/core";
|
|
150
|
+
|
|
151
|
+
// ─── Features ────────────────────────────────────────────────
|
|
152
|
+
|
|
153
|
+
const features = {
|
|
154
|
+
${featureEntries.join("\n")}
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
// ─── Plans ───────────────────────────────────────────────────
|
|
158
|
+
|
|
159
|
+
export default defineConfig({
|
|
160
|
+
features,
|
|
161
|
+
plans: {
|
|
162
|
+
${planEntries.join("\n")}
|
|
163
|
+
},
|
|
164
|
+
});
|
|
165
|
+
`;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// ─── Helpers ─────────────────────────────────────────────────
|
|
169
|
+
|
|
170
|
+
const API_BASE = "https://app.revstack.dev";
|
|
171
|
+
|
|
172
|
+
// ─── Command ─────────────────────────────────────────────────
|
|
173
|
+
|
|
174
|
+
export const pullCommand = new Command("pull")
|
|
175
|
+
.description(
|
|
176
|
+
"Pull the remote billing config and overwrite revstack.config.ts"
|
|
177
|
+
)
|
|
178
|
+
.option("-e, --env <environment>", "Target environment", "test")
|
|
179
|
+
.action(async (options: { env: string }) => {
|
|
180
|
+
const apiKey = getApiKey();
|
|
181
|
+
|
|
182
|
+
if (!apiKey) {
|
|
183
|
+
console.error(
|
|
184
|
+
"\n" +
|
|
185
|
+
chalk.red(" ✖ Not authenticated.\n") +
|
|
186
|
+
chalk.dim(" Run ") +
|
|
187
|
+
chalk.bold("revstack login") +
|
|
188
|
+
chalk.dim(" first.\n")
|
|
189
|
+
);
|
|
190
|
+
process.exit(1);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// ── 1. Fetch remote config ─────────────────────────────
|
|
194
|
+
const spinner = ora({
|
|
195
|
+
text: "Fetching remote configuration...",
|
|
196
|
+
prefixText: " ",
|
|
197
|
+
}).start();
|
|
198
|
+
|
|
199
|
+
let remoteConfig: RemoteConfig;
|
|
200
|
+
|
|
201
|
+
try {
|
|
202
|
+
const res = await fetch(
|
|
203
|
+
`${API_BASE}/api/v1/cli/pull?env=${encodeURIComponent(options.env)}`,
|
|
204
|
+
{
|
|
205
|
+
headers: { Authorization: `Bearer ${apiKey}` },
|
|
206
|
+
}
|
|
207
|
+
);
|
|
208
|
+
|
|
209
|
+
if (!res.ok) {
|
|
210
|
+
spinner.fail("Failed to fetch remote config");
|
|
211
|
+
console.error(
|
|
212
|
+
chalk.red(`\n API returned ${res.status}: ${res.statusText}\n`)
|
|
213
|
+
);
|
|
214
|
+
process.exit(1);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
remoteConfig = (await res.json()) as RemoteConfig;
|
|
218
|
+
spinner.succeed("Remote config fetched");
|
|
219
|
+
} catch (error: unknown) {
|
|
220
|
+
spinner.fail("Failed to reach Revstack Cloud");
|
|
221
|
+
console.error(chalk.red(`\n ${(error as Error).message}\n`));
|
|
222
|
+
process.exit(1);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// ── 2. Show summary ────────────────────────────────────
|
|
226
|
+
const featureCount = Object.keys(remoteConfig.features).length;
|
|
227
|
+
const planCount = Object.keys(remoteConfig.plans).length;
|
|
228
|
+
|
|
229
|
+
console.log(
|
|
230
|
+
"\n" +
|
|
231
|
+
chalk.dim(" Remote state: ") +
|
|
232
|
+
chalk.white(`${featureCount} features, ${planCount} plans`) +
|
|
233
|
+
chalk.dim(` (${options.env})\n`)
|
|
234
|
+
);
|
|
235
|
+
|
|
236
|
+
// ── 3. Confirm overwrite ───────────────────────────────
|
|
237
|
+
const configPath = path.resolve(process.cwd(), "revstack.config.ts");
|
|
238
|
+
const exists = fs.existsSync(configPath);
|
|
239
|
+
|
|
240
|
+
if (exists) {
|
|
241
|
+
const { confirm } = await prompts({
|
|
242
|
+
type: "confirm",
|
|
243
|
+
name: "confirm",
|
|
244
|
+
message:
|
|
245
|
+
"This will overwrite your local revstack.config.ts. Are you sure?",
|
|
246
|
+
initial: false,
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
if (!confirm) {
|
|
250
|
+
console.log(chalk.dim("\n Pull cancelled.\n"));
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// ── 4. Generate and write ──────────────────────────────
|
|
256
|
+
const source = generateConfigSource(remoteConfig);
|
|
257
|
+
fs.writeFileSync(configPath, source, "utf-8");
|
|
258
|
+
|
|
259
|
+
console.log(
|
|
260
|
+
"\n" +
|
|
261
|
+
chalk.green(" ✔ revstack.config.ts updated from remote.\n") +
|
|
262
|
+
chalk.dim(" Review the file and run ") +
|
|
263
|
+
chalk.bold("revstack push") +
|
|
264
|
+
chalk.dim(" to re-deploy.\n")
|
|
265
|
+
);
|
|
266
|
+
});
|