Skip to content

Commit 57149bd

Browse files
authored
Stack CLI (#1227)
<!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit ## Release Notes * **New Features** * Added Stack CLI with authentication (login/logout) commands. * Added project management commands to list and create projects. * Added configuration management to pull and push project settings. * Added code execution capability to run JavaScript expressions. * Added initialization command for Stack Auth setup. * **Tests** * Added comprehensive end-to-end test suite for CLI functionality. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
1 parent d0879ac commit 57149bd

File tree

18 files changed

+1055
-73
lines changed

18 files changed

+1055
-73
lines changed

apps/e2e/tests/general/cli.test.ts

Lines changed: 348 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,348 @@
1+
import { execFile } from "child_process";
2+
import * as fs from "fs";
3+
import * as os from "os";
4+
import * as path from "path";
5+
import { StackAdminApp } from "@stackframe/js";
6+
import { Result } from "@stackframe/stack-shared/dist/utils/results";
7+
import { describe, beforeAll, afterAll } from "vitest";
8+
import { it, niceFetch, STACK_BACKEND_BASE_URL, STACK_INTERNAL_PROJECT_CLIENT_KEY, STACK_INTERNAL_PROJECT_SERVER_KEY, STACK_INTERNAL_PROJECT_ADMIN_KEY } from "../helpers";
9+
10+
const CLI_BIN = path.resolve("packages/stack-cli/dist/index.js");
11+
12+
function runCli(
13+
args: string[],
14+
envOverrides?: Record<string, string>,
15+
): Promise<{ stdout: string, stderr: string, exitCode: number | null }> {
16+
return new Promise((resolve) => {
17+
execFile("node", [CLI_BIN, ...args], {
18+
env: { ...baseEnv, ...envOverrides },
19+
timeout: 30_000,
20+
}, (error, stdout, stderr) => {
21+
resolve({
22+
stdout: stdout.toString(),
23+
stderr: stderr.toString(),
24+
exitCode: error ? (error as any).code ?? 1 : 0,
25+
});
26+
});
27+
});
28+
}
29+
30+
let baseEnv: Record<string, string>;
31+
let tmpDir: string;
32+
let configFilePath: string;
33+
let refreshToken: string;
34+
35+
describe("Stack CLI", () => {
36+
beforeAll(async () => {
37+
// Check CLI is built
38+
if (!fs.existsSync(CLI_BIN)) {
39+
throw new Error("CLI not built. Run `pnpm --filter @stackframe/stack-cli run build` first.");
40+
}
41+
42+
// Create temp dir for config file
43+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "stack-cli-test-"));
44+
configFilePath = path.join(tmpDir, "credentials.json");
45+
46+
// Create test user on internal project (auto-creates team)
47+
const internalApp = new StackAdminApp({
48+
projectId: "internal",
49+
baseUrl: STACK_BACKEND_BASE_URL,
50+
publishableClientKey: STACK_INTERNAL_PROJECT_CLIENT_KEY,
51+
secretServerKey: STACK_INTERNAL_PROJECT_SERVER_KEY,
52+
superSecretAdminKey: STACK_INTERNAL_PROJECT_ADMIN_KEY,
53+
tokenStore: "memory",
54+
});
55+
56+
const fakeEmail = `cli-test-${crypto.randomUUID()}@stack-generated.example.com`;
57+
Result.orThrow(await internalApp.signUpWithCredential({
58+
email: fakeEmail,
59+
password: "test-password-123",
60+
verificationCallbackUrl: "http://localhost:3000",
61+
}));
62+
63+
const user = await internalApp.getUser({ or: "throw" });
64+
65+
// Create a session to get a refresh token
66+
const sessionRes = await niceFetch(`${STACK_BACKEND_BASE_URL}/api/v1/auth/sessions`, {
67+
method: "POST",
68+
headers: {
69+
"content-type": "application/json",
70+
"x-stack-access-type": "server",
71+
"x-stack-project-id": "internal",
72+
"x-stack-publishable-client-key": STACK_INTERNAL_PROJECT_CLIENT_KEY,
73+
"x-stack-secret-server-key": STACK_INTERNAL_PROJECT_SERVER_KEY,
74+
},
75+
body: JSON.stringify({
76+
user_id: user.id,
77+
expires_in_millis: 1000 * 60 * 60 * 24,
78+
is_impersonation: false,
79+
}),
80+
});
81+
82+
if (sessionRes.status !== 200) {
83+
throw new Error(`Failed to create session: ${sessionRes.status} ${JSON.stringify(sessionRes.body)}`);
84+
}
85+
refreshToken = sessionRes.body.refresh_token;
86+
87+
// Set base env for CLI
88+
baseEnv = {
89+
PATH: process.env.PATH ?? "",
90+
HOME: process.env.HOME ?? "",
91+
STACK_API_URL: STACK_BACKEND_BASE_URL,
92+
STACK_CLI_REFRESH_TOKEN: refreshToken,
93+
STACK_CLI_PUBLISHABLE_CLIENT_KEY: STACK_INTERNAL_PROJECT_CLIENT_KEY,
94+
STACK_CLI_CONFIG_PATH: configFilePath,
95+
CI: "1",
96+
};
97+
}, 120_000);
98+
99+
afterAll(() => {
100+
if (tmpDir && fs.existsSync(tmpDir)) {
101+
fs.rmSync(tmpDir, { recursive: true });
102+
}
103+
});
104+
105+
it("shows help output", async ({ expect }) => {
106+
const { stdout, exitCode } = await runCli(["--help"]);
107+
expect(exitCode).toBe(0);
108+
expect(stdout).toContain("Stack Auth CLI");
109+
});
110+
111+
it("shows version output", async ({ expect }) => {
112+
const pkg = JSON.parse(fs.readFileSync(path.resolve("packages/stack-cli/package.json"), "utf-8"));
113+
const { stdout, exitCode } = await runCli(["--version"]);
114+
expect(exitCode).toBe(0);
115+
expect(stdout.trim()).toBe(pkg.version);
116+
});
117+
118+
it("errors when not logged in", async ({ expect }) => {
119+
const { stderr, exitCode } = await runCli(["project", "list"], {
120+
STACK_CLI_REFRESH_TOKEN: "",
121+
});
122+
expect(exitCode).toBe(1);
123+
expect(stderr).toContain("Not logged in");
124+
});
125+
126+
it("errors when no project ID given", async ({ expect }) => {
127+
const { stderr, exitCode } = await runCli(["exec", "return 1"]);
128+
expect(exitCode).toBe(1);
129+
expect(stderr).toContain("No project ID");
130+
});
131+
132+
it("logout clears config", async ({ expect }) => {
133+
// Write a fake token to the config file
134+
fs.writeFileSync(configFilePath, JSON.stringify({ STACK_CLI_REFRESH_TOKEN: "fake-token" }), { mode: 0o600 });
135+
136+
const { stdout, exitCode } = await runCli(["logout"]);
137+
expect(exitCode).toBe(0);
138+
expect(stdout).toContain("Logged out");
139+
140+
const content = fs.readFileSync(configFilePath, "utf-8");
141+
expect(content).not.toContain("fake-token");
142+
});
143+
144+
let createdProjectId: string;
145+
146+
it("lists projects as empty JSON array", async ({ expect }) => {
147+
const { stdout, exitCode } = await runCli(["--json", "project", "list"]);
148+
expect(exitCode).toBe(0);
149+
const projects = JSON.parse(stdout);
150+
expect(Array.isArray(projects)).toBe(true);
151+
});
152+
153+
it("creates a project", async ({ expect }) => {
154+
const { stdout, exitCode } = await runCli(["--json", "project", "create", "--display-name", "CLI Test"]);
155+
expect(exitCode).toBe(0);
156+
const project = JSON.parse(stdout);
157+
expect(project).toHaveProperty("id");
158+
expect(project).toHaveProperty("displayName");
159+
expect(project.displayName).toBe("CLI Test");
160+
createdProjectId = project.id;
161+
});
162+
163+
it("lists projects including created one", async ({ expect }) => {
164+
expect(createdProjectId).toBeDefined();
165+
const { stdout, exitCode } = await runCli(["--json", "project", "list"]);
166+
expect(exitCode).toBe(0);
167+
const projects = JSON.parse(stdout);
168+
const found = projects.find((p: any) => p.id === createdProjectId);
169+
expect(found).toBeDefined();
170+
expect(found.displayName).toBe("CLI Test");
171+
});
172+
173+
it("returns basic expression", async ({ expect }) => {
174+
expect(createdProjectId).toBeDefined();
175+
const { stdout, exitCode } = await runCli(
176+
["exec", "return 1+1"],
177+
{ STACK_PROJECT_ID: createdProjectId },
178+
);
179+
expect(exitCode).toBe(0);
180+
expect(stdout.trim()).toBe("2");
181+
});
182+
183+
it("has stackServerApp object available", async ({ expect }) => {
184+
const { stdout, exitCode } = await runCli(
185+
["exec", "return typeof stackServerApp"],
186+
{ STACK_PROJECT_ID: createdProjectId },
187+
);
188+
expect(exitCode).toBe(0);
189+
expect(stdout.trim()).toBe('"object"');
190+
});
191+
192+
it("exec help mentions docs URL", async ({ expect }) => {
193+
const { stdout, exitCode } = await runCli(["exec", "--help"]);
194+
expect(exitCode).toBe(0);
195+
expect(stdout).toContain("https://docs.stack-auth.com/docs/sdk");
196+
});
197+
198+
it("errors when no javascript is provided", async ({ expect }) => {
199+
const { stderr, exitCode } = await runCli(["exec"], { STACK_PROJECT_ID: createdProjectId });
200+
expect(exitCode).toBe(1);
201+
expect(stderr).toContain("Missing JavaScript argument");
202+
});
203+
204+
it("reports syntax error", async ({ expect }) => {
205+
const { stderr, exitCode } = await runCli(
206+
["exec", "return @@invalid"],
207+
{ STACK_PROJECT_ID: createdProjectId },
208+
);
209+
expect(exitCode).toBe(1);
210+
expect(stderr).toContain("Syntax error");
211+
});
212+
213+
it("reports runtime error", async ({ expect }) => {
214+
const { stderr, exitCode } = await runCli(
215+
["exec", "throw new Error('boom')"],
216+
{ STACK_PROJECT_ID: createdProjectId },
217+
);
218+
expect(exitCode).toBe(1);
219+
expect(stderr).toContain("boom");
220+
});
221+
222+
it("reports string runtime error", async ({ expect }) => {
223+
const { stderr, exitCode } = await runCli(
224+
["exec", "throw 'boom-string'"],
225+
{ STACK_PROJECT_ID: createdProjectId },
226+
);
227+
expect(exitCode).toBe(1);
228+
expect(stderr).toContain("boom-string");
229+
});
230+
231+
it("reports object runtime error", async ({ expect }) => {
232+
const { stderr, exitCode } = await runCli(
233+
["exec", "throw { code: 123 }"],
234+
{ STACK_PROJECT_ID: createdProjectId },
235+
);
236+
expect(exitCode).toBe(1);
237+
expect(stderr).toContain('{"code":123}');
238+
});
239+
240+
it("reports undefined variable", async ({ expect }) => {
241+
const { stderr, exitCode } = await runCli(
242+
["exec", "return nonExistentVar"],
243+
{ STACK_PROJECT_ID: createdProjectId },
244+
);
245+
expect(exitCode).toBe(1);
246+
expect(stderr).toContain("nonExistentVar");
247+
});
248+
249+
it("returns undefined for no return value", async ({ expect }) => {
250+
const { stdout, exitCode } = await runCli(
251+
["exec", "const x = 1"],
252+
{ STACK_PROJECT_ID: createdProjectId },
253+
);
254+
expect(exitCode).toBe(0);
255+
expect(stdout.trim()).toBe("");
256+
});
257+
258+
it("returns complex object as JSON", async ({ expect }) => {
259+
const { stdout, exitCode } = await runCli(
260+
["exec", "return {a: 1, b: [2, 3]}"],
261+
{ STACK_PROJECT_ID: createdProjectId },
262+
);
263+
expect(exitCode).toBe(0);
264+
const parsed = JSON.parse(stdout);
265+
expect(parsed).toEqual({ a: 1, b: [2, 3] });
266+
});
267+
268+
it("supports async code", async ({ expect }) => {
269+
const { stdout, exitCode } = await runCli(
270+
["exec", "return await Promise.resolve(42)"],
271+
{ STACK_PROJECT_ID: createdProjectId },
272+
);
273+
expect(exitCode).toBe(0);
274+
expect(stdout.trim()).toBe("42");
275+
});
276+
277+
let createdUserEmail: string;
278+
279+
it("can create user with stackServerApp", async ({ expect }) => {
280+
createdUserEmail = `exec-test-${crypto.randomUUID()}@stack-generated.example.com`;
281+
const code = `const u = await stackServerApp.createUser({ primaryEmail: "${createdUserEmail}", password: "test123456" }); return { id: u.id, email: u.primaryEmail }`;
282+
const { stdout, exitCode } = await runCli(
283+
["exec", code],
284+
{ STACK_PROJECT_ID: createdProjectId },
285+
);
286+
expect(exitCode).toBe(0);
287+
const parsed = JSON.parse(stdout);
288+
expect(parsed).toHaveProperty("id");
289+
expect(parsed.email).toBe(createdUserEmail);
290+
});
291+
292+
it("can list users with stackServerApp", async ({ expect }) => {
293+
expect(createdProjectId).toBeDefined();
294+
expect(createdUserEmail).toBeDefined();
295+
const { stdout, exitCode } = await runCli(
296+
["exec", "const users = await stackServerApp.listUsers(); return users.length"],
297+
{ STACK_PROJECT_ID: createdProjectId },
298+
);
299+
expect(exitCode).toBe(0);
300+
const count = JSON.parse(stdout);
301+
expect(count).toBeGreaterThanOrEqual(1);
302+
});
303+
304+
let configTsPath: string;
305+
306+
it("config pull writes a .ts file", async ({ expect }) => {
307+
configTsPath = path.join(tmpDir, "config.ts");
308+
const { stdout, exitCode } = await runCli(
309+
["config", "pull", "--config-file", configTsPath],
310+
{ STACK_PROJECT_ID: createdProjectId },
311+
);
312+
expect(exitCode).toBe(0);
313+
expect(stdout).toContain("Config written to");
314+
const content = fs.readFileSync(configTsPath, "utf-8");
315+
expect(content).toContain("export const config");
316+
});
317+
318+
it("config push succeeds", async ({ expect }) => {
319+
expect(configTsPath).toBeDefined();
320+
const { stdout, exitCode } = await runCli(
321+
["config", "push", "--config-file", configTsPath],
322+
{ STACK_PROJECT_ID: createdProjectId },
323+
);
324+
expect(exitCode).toBe(0);
325+
expect(stdout).toContain("Config pushed successfully");
326+
});
327+
328+
it("config pull rejects bad extension", async ({ expect }) => {
329+
const badPath = path.join(tmpDir, "config.json");
330+
const { stderr, exitCode } = await runCli(
331+
["config", "pull", "--config-file", badPath],
332+
{ STACK_PROJECT_ID: createdProjectId },
333+
);
334+
expect(exitCode).toBe(1);
335+
expect(stderr).toContain(".js or .ts");
336+
});
337+
338+
it("config push rejects array config export", async ({ expect }) => {
339+
const badConfigPath = path.join(tmpDir, "config-array.ts");
340+
fs.writeFileSync(badConfigPath, "export const config = [];\n");
341+
const { stderr, exitCode } = await runCli(
342+
["config", "push", "--config-file", badConfigPath],
343+
{ STACK_PROJECT_ID: createdProjectId },
344+
);
345+
expect(exitCode).toBe(1);
346+
expect(stderr).toContain("plain `config` object");
347+
});
348+
});

packages/stack-cli/.eslintrc.cjs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
module.exports = {
2+
"extends": [
3+
"../../configs/eslint/defaults.js",
4+
],
5+
"ignorePatterns": ['/*', '!/src'],
6+
};

packages/stack-cli/package.json

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
{
2+
"name": "@stackframe/stack-cli",
3+
"version": "2.8.71",
4+
"repository": "https://github.com/stack-auth/stack-auth",
5+
"description": "The CLI for Stack Auth. https://stack-auth.com",
6+
"main": "dist/index.js",
7+
"type": "module",
8+
"bin": {
9+
"stack": "./dist/index.js"
10+
},
11+
"scripts": {
12+
"clean": "rimraf node_modules && rimraf dist",
13+
"build": "tsdown",
14+
"dev": "tsdown --watch",
15+
"lint": "eslint --ext .tsx,.ts .",
16+
"typecheck": "tsc --noEmit"
17+
},
18+
"files": [
19+
"README.md",
20+
"dist",
21+
"CHANGELOG.md",
22+
"LICENSE"
23+
],
24+
"homepage": "https://stack-auth.com",
25+
"keywords": [],
26+
"author": "",
27+
"license": "MIT",
28+
"dependencies": {
29+
"@stackframe/js": "workspace:*",
30+
"commander": "^13.1.0",
31+
"jiti": "^2.4.2"
32+
},
33+
"devDependencies": {
34+
"@types/node": "20.17.6",
35+
"rimraf": "^6.0.1",
36+
"tsdown": "^0.20.3",
37+
"typescript": "5.9.3"
38+
},
39+
"packageManager": "pnpm@10.23.0"
40+
}

0 commit comments

Comments
 (0)