@revstackhq/cli 0.0.0-dev-20260226055348 → 0.0.0-dev-20260226061807

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.
@@ -52,19 +52,9 @@ interface RemoteConfig {
52
52
  plans: Record<string, RemotePlan>;
53
53
  }
54
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
55
  function serializeObject(
66
56
  obj: Record<string, unknown>,
67
- depth: number = 0
57
+ depth: number = 0,
68
58
  ): string {
69
59
  const entries = Object.entries(obj);
70
60
  if (entries.length === 0) return "{}";
@@ -110,8 +100,7 @@ function serializeArray(arr: unknown[], depth: number): string {
110
100
  return `[\n${items.join("\n")}\n${closePad}]`;
111
101
  }
112
102
 
113
- function generateConfigSource(config: RemoteConfig): string {
114
- // ── Features ─────────────────────────────────────────────
103
+ function generateFeaturesSource(config: RemoteConfig): string {
115
104
  const featureEntries = Object.entries(config.features).map(([slug, f]) => {
116
105
  const props: Record<string, unknown> = {
117
106
  name: f.name,
@@ -123,7 +112,15 @@ function generateConfigSource(config: RemoteConfig): string {
123
112
  return ` ${slug}: defineFeature(${serializeObject(props, 2)}),`;
124
113
  });
125
114
 
126
- // ── Plans ────────────────────────────────────────────────
115
+ return `import { defineFeature } from "@revstackhq/core";
116
+
117
+ export const features = {
118
+ ${featureEntries.join("\n")}
119
+ };
120
+ `;
121
+ }
122
+
123
+ function generatePlansSource(config: RemoteConfig): string {
127
124
  const planEntries = Object.entries(config.plans).map(([slug, plan]) => {
128
125
  const props: Record<string, unknown> = {
129
126
  name: plan.name,
@@ -146,21 +143,23 @@ function generateConfigSource(config: RemoteConfig): string {
146
143
  return `${comment} ${slug}: definePlan<typeof features>(${serializeObject(props, 3)}),`;
147
144
  });
148
145
 
149
- return `import { defineConfig, definePlan, defineFeature } from "@revstackhq/core";
150
-
151
- // ─── Features ────────────────────────────────────────────────
146
+ return `import { definePlan } from "@revstackhq/core";
147
+ import { features } from "./features";
152
148
 
153
- const features = {
154
- ${featureEntries.join("\n")}
149
+ export const plans = {
150
+ ${planEntries.join("\n")}
155
151
  };
152
+ `;
153
+ }
156
154
 
157
- // ─── Plans ───────────────────────────────────────────────────
155
+ function generateRootConfigSource(): string {
156
+ return `import { defineConfig } from "@revstackhq/core";
157
+ import { features } from "./revstack/features";
158
+ import { plans } from "./revstack/plans";
158
159
 
159
160
  export default defineConfig({
160
161
  features,
161
- plans: {
162
- ${planEntries.join("\n")}
163
- },
162
+ plans,
164
163
  });
165
164
  `;
166
165
  }
@@ -173,7 +172,7 @@ const API_BASE = "https://app.revstack.dev";
173
172
 
174
173
  export const pullCommand = new Command("pull")
175
174
  .description(
176
- "Pull the remote billing config and overwrite revstack.config.ts"
175
+ "Pull the remote billing config and overwrite local revstack.config.ts and revstack/ files",
177
176
  )
178
177
  .option("-e, --env <environment>", "Target environment", "test")
179
178
  .action(async (options: { env: string }) => {
@@ -185,7 +184,7 @@ export const pullCommand = new Command("pull")
185
184
  chalk.red(" ✖ Not authenticated.\n") +
186
185
  chalk.dim(" Run ") +
187
186
  chalk.bold("revstack login") +
188
- chalk.dim(" first.\n")
187
+ chalk.dim(" first.\n"),
189
188
  );
190
189
  process.exit(1);
191
190
  }
@@ -203,13 +202,13 @@ export const pullCommand = new Command("pull")
203
202
  `${API_BASE}/api/v1/cli/pull?env=${encodeURIComponent(options.env)}`,
204
203
  {
205
204
  headers: { Authorization: `Bearer ${apiKey}` },
206
- }
205
+ },
207
206
  );
208
207
 
209
208
  if (!res.ok) {
210
209
  spinner.fail("Failed to fetch remote config");
211
210
  console.error(
212
- chalk.red(`\n API returned ${res.status}: ${res.statusText}\n`)
211
+ chalk.red(`\n API returned ${res.status}: ${res.statusText}\n`),
213
212
  );
214
213
  process.exit(1);
215
214
  }
@@ -230,19 +229,25 @@ export const pullCommand = new Command("pull")
230
229
  "\n" +
231
230
  chalk.dim(" Remote state: ") +
232
231
  chalk.white(`${featureCount} features, ${planCount} plans`) +
233
- chalk.dim(` (${options.env})\n`)
232
+ chalk.dim(` (${options.env})\n`),
234
233
  );
235
234
 
236
235
  // ── 3. Confirm overwrite ───────────────────────────────
237
- const configPath = path.resolve(process.cwd(), "revstack.config.ts");
238
- const exists = fs.existsSync(configPath);
236
+ const cwd = process.cwd();
237
+ const configPath = path.resolve(cwd, "revstack.config.ts");
238
+ const revstackDir = path.resolve(cwd, "revstack");
239
+ const featuresPath = path.resolve(revstackDir, "features.ts");
240
+ const plansPath = path.resolve(revstackDir, "plans.ts");
239
241
 
240
- if (exists) {
242
+ const rootExists = fs.existsSync(configPath);
243
+ const dirExists = fs.existsSync(revstackDir);
244
+
245
+ if (rootExists || dirExists) {
241
246
  const { confirm } = await prompts({
242
247
  type: "confirm",
243
248
  name: "confirm",
244
249
  message:
245
- "This will overwrite your local revstack.config.ts. Are you sure?",
250
+ "This will overwrite your local configuration files (revstack.config.ts and revstack/ data). Are you sure?",
246
251
  initial: false,
247
252
  });
248
253
 
@@ -253,14 +258,23 @@ export const pullCommand = new Command("pull")
253
258
  }
254
259
 
255
260
  // ── 4. Generate and write ──────────────────────────────
256
- const source = generateConfigSource(remoteConfig);
257
- fs.writeFileSync(configPath, source, "utf-8");
261
+ if (!fs.existsSync(revstackDir)) {
262
+ fs.mkdirSync(revstackDir, { recursive: true });
263
+ }
264
+
265
+ const featuresSource = generateFeaturesSource(remoteConfig);
266
+ const plansSource = generatePlansSource(remoteConfig);
267
+ const rootSource = generateRootConfigSource();
268
+
269
+ fs.writeFileSync(featuresPath, featuresSource, "utf-8");
270
+ fs.writeFileSync(plansPath, plansSource, "utf-8");
271
+ fs.writeFileSync(configPath, rootSource, "utf-8");
258
272
 
259
273
  console.log(
260
274
  "\n" +
261
- chalk.green(" ✔ revstack.config.ts updated from remote.\n") +
262
- chalk.dim(" Review the file and run ") +
275
+ chalk.green(" ✔ Local files updated from remote.\n") +
276
+ chalk.dim(" Review the files and run ") +
263
277
  chalk.bold("revstack push") +
264
- chalk.dim(" to re-deploy.\n")
278
+ chalk.dim(" to re-deploy.\n"),
265
279
  );
266
280
  });
@@ -6,9 +6,27 @@ vi.mock("node:fs", () => ({
6
6
  default: {
7
7
  existsSync: vi.fn(),
8
8
  writeFileSync: vi.fn(),
9
+ mkdirSync: vi.fn(),
9
10
  },
10
11
  existsSync: vi.fn(),
11
12
  writeFileSync: vi.fn(),
13
+ mkdirSync: vi.fn(),
14
+ }));
15
+
16
+ // ── Mock node:child_process ────────────────────────────────────
17
+ vi.mock("node:child_process", () => ({
18
+ spawnSync: vi.fn().mockReturnValue({ status: 0, error: null }),
19
+ }));
20
+
21
+ // ── Mock ora ─────────────────────────────────────────────────
22
+ const mockSpinner = {
23
+ start: vi.fn().mockReturnThis(),
24
+ succeed: vi.fn().mockReturnThis(),
25
+ fail: vi.fn().mockReturnThis(),
26
+ };
27
+
28
+ vi.mock("ora", () => ({
29
+ default: vi.fn(() => mockSpinner),
12
30
  }));
13
31
 
14
32
  // ── Mock chalk (no-op proxy) ─────────────────────────────────
@@ -67,13 +85,28 @@ describe("init command", () => {
67
85
  const program = createTestProgram();
68
86
  await program.parseAsync(["node", "revstack", "init"], { from: "node" });
69
87
 
88
+ expect(mockFs.mkdirSync).toHaveBeenCalledWith(
89
+ expect.stringContaining("revstack"),
90
+ { recursive: true },
91
+ );
92
+ expect(mockFs.writeFileSync).toHaveBeenCalledTimes(3);
93
+ expect(mockFs.writeFileSync).toHaveBeenCalledWith(
94
+ expect.stringContaining("features.ts"),
95
+ expect.stringContaining("defineFeature"),
96
+ "utf-8",
97
+ );
98
+ expect(mockFs.writeFileSync).toHaveBeenCalledWith(
99
+ expect.stringContaining("plans.ts"),
100
+ expect.stringContaining("definePlan"),
101
+ "utf-8",
102
+ );
70
103
  expect(mockFs.writeFileSync).toHaveBeenCalledWith(
71
104
  expect.stringContaining("revstack.config.ts"),
72
105
  expect.stringContaining("defineConfig"),
73
- "utf-8"
106
+ "utf-8",
74
107
  );
75
108
  expect(consoleSpy).toHaveBeenCalledWith(
76
- expect.stringContaining("Created revstack.config.ts")
109
+ expect.stringContaining("Created revstack config structure"),
77
110
  );
78
111
 
79
112
  consoleSpy.mockRestore();
@@ -86,7 +119,7 @@ describe("init command", () => {
86
119
  const program = createTestProgram();
87
120
 
88
121
  await expect(
89
- program.parseAsync(["node", "revstack", "init"], { from: "node" })
122
+ program.parseAsync(["node", "revstack", "init"], { from: "node" }),
90
123
  ).rejects.toThrow(ProcessExitError);
91
124
 
92
125
  expect(exitSpy).toHaveBeenCalledWith(1);
@@ -37,9 +37,11 @@ vi.mock("node:fs", () => ({
37
37
  default: {
38
38
  existsSync: vi.fn(),
39
39
  writeFileSync: vi.fn(),
40
+ mkdirSync: vi.fn(),
40
41
  },
41
42
  existsSync: vi.fn(),
42
43
  writeFileSync: vi.fn(),
44
+ mkdirSync: vi.fn(),
43
45
  }));
44
46
 
45
47
  // ── Imports (after mocks) ────────────────────────────────────
@@ -130,7 +132,7 @@ describe("pull command", () => {
130
132
  const program = createTestProgram();
131
133
 
132
134
  await expect(
133
- program.parseAsync(["node", "revstack", "pull"], { from: "node" })
135
+ program.parseAsync(["node", "revstack", "pull"], { from: "node" }),
134
136
  ).rejects.toThrow(ProcessExitError);
135
137
 
136
138
  expect(exitSpy).toHaveBeenCalledWith(1);
@@ -148,7 +150,7 @@ describe("pull command", () => {
148
150
  const program = createTestProgram();
149
151
 
150
152
  await expect(
151
- program.parseAsync(["node", "revstack", "pull"], { from: "node" })
153
+ program.parseAsync(["node", "revstack", "pull"], { from: "node" }),
152
154
  ).rejects.toThrow(ProcessExitError);
153
155
 
154
156
  expect(exitSpy).toHaveBeenCalledWith(1);
@@ -157,7 +159,9 @@ describe("pull command", () => {
157
159
 
158
160
  it("pulls config and writes file after user confirmation", async () => {
159
161
  mockGetApiKey.mockReturnValue("sk_test_valid123");
160
- mockFs.existsSync.mockReturnValue(true); // config file exists
162
+ mockFs.existsSync.mockImplementation((p) =>
163
+ String(p).endsWith("revstack.config.ts"),
164
+ ); // config file exists
161
165
 
162
166
  const mockFetch = vi
163
167
  .fn()
@@ -171,10 +175,25 @@ describe("pull command", () => {
171
175
 
172
176
  expect(mockFetch).toHaveBeenCalledTimes(1);
173
177
  expect(mockFetch.mock.calls[0][0]).toContain("/api/v1/cli/pull");
178
+ expect(mockFs.mkdirSync).toHaveBeenCalledWith(
179
+ expect.stringContaining("revstack"),
180
+ { recursive: true },
181
+ );
182
+ expect(mockFs.writeFileSync).toHaveBeenCalledTimes(3);
183
+ expect(mockFs.writeFileSync).toHaveBeenCalledWith(
184
+ expect.stringContaining("features.ts"),
185
+ expect.stringContaining("defineFeature"),
186
+ "utf-8",
187
+ );
188
+ expect(mockFs.writeFileSync).toHaveBeenCalledWith(
189
+ expect.stringContaining("plans.ts"),
190
+ expect.stringContaining("definePlan"),
191
+ "utf-8",
192
+ );
174
193
  expect(mockFs.writeFileSync).toHaveBeenCalledWith(
175
194
  expect.stringContaining("revstack.config.ts"),
176
195
  expect.stringContaining("defineConfig"),
177
- "utf-8"
196
+ "utf-8",
178
197
  );
179
198
  });
180
199
 
@@ -193,6 +212,7 @@ describe("pull command", () => {
193
212
  await program.parseAsync(["node", "revstack", "pull"], { from: "node" });
194
213
 
195
214
  expect(mockFs.writeFileSync).not.toHaveBeenCalled();
215
+ expect(mockFs.mkdirSync).not.toHaveBeenCalled();
196
216
  });
197
217
 
198
218
  it("skips confirmation when config file does not exist", async () => {
@@ -211,6 +231,6 @@ describe("pull command", () => {
211
231
  // Prompts never called since file doesn't exist
212
232
  expect(mockPrompts).not.toHaveBeenCalled();
213
233
  // File was written directly
214
- expect(mockFs.writeFileSync).toHaveBeenCalled();
234
+ expect(mockFs.writeFileSync).toHaveBeenCalledTimes(3);
215
235
  });
216
236
  });
package/tsup.config.ts CHANGED
@@ -1,9 +1,10 @@
1
1
  import { defineConfig } from "tsup";
2
+ import { execa } from "execa";
2
3
 
3
4
  export default defineConfig({
4
- entry: ["src/cli.ts"],
5
+ entry: ["src/**/*.ts"],
5
6
  format: ["esm"],
6
- dts: false,
7
+ dts: true,
7
8
  splitting: false,
8
9
  sourcemap: true,
9
10
  clean: true,
@@ -13,4 +14,9 @@ export default defineConfig({
13
14
  banner: {
14
15
  js: "#!/usr/bin/env node",
15
16
  },
17
+ async onSuccess() {
18
+ await execa("tsc-alias", ["-p", "tsconfig.json"], {
19
+ stdio: "inherit",
20
+ });
21
+ },
16
22
  });