@stackframe/stack-cli 2.8.83 → 2.8.85

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/dist/index.js CHANGED
@@ -7,6 +7,7 @@ import * as path from "path";
7
7
  import { dirname, join, resolve } from "path";
8
8
  import { StackClientApp } from "@stackframe/js";
9
9
  import * as os from "os";
10
+ import { homedir } from "os";
10
11
  import { detectImportPackageFromDir, renderConfigFileContent } from "@stackframe/stack-shared/dist/config-rendering";
11
12
  import { checkbox, confirm, input, select } from "@inquirer/prompts";
12
13
  import { ALL_APPS } from "@stackframe/stack-shared/dist/apps/apps-config";
@@ -72,6 +73,9 @@ function resolveRefreshToken() {
72
73
  if (!token) throw new AuthError("Not logged in. Run `stack login` first.");
73
74
  return token;
74
75
  }
76
+ function resolveSecretServerKey() {
77
+ return process.env.STACK_SECRET_SERVER_KEY ?? null;
78
+ }
75
79
  function resolveProjectId(flags) {
76
80
  const projectId = flags.projectId ?? process.env.STACK_PROJECT_ID;
77
81
  if (!projectId) throw new AuthError("No project ID specified. Use --project-id or set STACK_PROJECT_ID.");
@@ -90,11 +94,23 @@ function resolveSessionAuth(flags) {
90
94
  };
91
95
  }
92
96
  function resolveAuth(flags) {
97
+ const secretServerKey = resolveSecretServerKey();
98
+ if (secretServerKey) return {
99
+ ...resolveLoginConfig(flags),
100
+ projectId: resolveProjectId(flags),
101
+ secretServerKey
102
+ };
93
103
  return {
94
104
  ...resolveSessionAuth(flags),
95
105
  projectId: resolveProjectId(flags)
96
106
  };
97
107
  }
108
+ function isProjectAuthWithSecretServerKey(auth) {
109
+ return "secretServerKey" in auth;
110
+ }
111
+ function isProjectAuthWithRefreshToken(auth) {
112
+ return "refreshToken" in auth;
113
+ }
98
114
 
99
115
  //#endregion
100
116
  //#region src/commands/login.ts
@@ -170,7 +186,9 @@ function getErrorMessage(err) {
170
186
  function registerExecCommand(program) {
171
187
  program.command("exec [javascript]").description("Execute JavaScript with a pre-configured StackServerApp as `stackServerApp`").addHelpText("after", "\nFor available API methods, see: https://docs.stack-auth.com/docs/sdk").action(async (javascript) => {
172
188
  if (javascript === void 0) throw new CliError("Missing JavaScript argument. Use `stack exec \"<javascript>\"` or `stack exec --help`.");
173
- const project = await getAdminProject(resolveAuth(program.opts()));
189
+ const auth = resolveAuth(program.opts());
190
+ if (!isProjectAuthWithRefreshToken(auth)) throw new CliError("`stack exec` requires `stack login`. Remove STACK_SECRET_SERVER_KEY and try again.");
191
+ const project = await getAdminProject(auth);
174
192
  const AsyncFunction = Object.getPrototypeOf(async function() {}).constructor;
175
193
  let fn;
176
194
  try {
@@ -190,15 +208,72 @@ function registerExecCommand(program) {
190
208
 
191
209
  //#endregion
192
210
  //#region src/commands/config-file.ts
193
- function isPlainObject(value) {
211
+ function isConfigOverride(value) {
194
212
  if (value === null || typeof value !== "object" || Array.isArray(value)) return false;
195
213
  const prototype = Object.getPrototypeOf(value);
196
214
  return prototype === Object.prototype || prototype === null;
197
215
  }
216
+ function parseGitHubRepository() {
217
+ const repository = process.env.GITHUB_REPOSITORY;
218
+ if (!repository) return null;
219
+ const slashIndex = repository.indexOf("/");
220
+ if (slashIndex <= 0 || slashIndex >= repository.length - 1) return null;
221
+ return {
222
+ owner: repository.slice(0, slashIndex),
223
+ repo: repository.slice(slashIndex + 1)
224
+ };
225
+ }
226
+ function buildConfigPushSource(configFilePath) {
227
+ const repository = parseGitHubRepository();
228
+ const sha = process.env.GITHUB_SHA;
229
+ const branch = process.env.GITHUB_REF_NAME;
230
+ if (repository && sha && branch) return {
231
+ type: "pushed-from-github",
232
+ owner: repository.owner,
233
+ repo: repository.repo,
234
+ branch,
235
+ commit_hash: sha,
236
+ config_file_path: configFilePath
237
+ };
238
+ return { type: "pushed-from-unknown" };
239
+ }
240
+ async function pushConfigWithSecretServerKey(auth, config, source) {
241
+ const endpoint = `${auth.apiUrl.replace(/\/$/, "")}/api/v1/internal/config/override/branch`;
242
+ const response = await fetch(endpoint, {
243
+ method: "PUT",
244
+ headers: {
245
+ "content-type": "application/json",
246
+ "x-stack-project-id": auth.projectId,
247
+ "x-stack-access-type": "server",
248
+ "x-stack-secret-server-key": auth.secretServerKey
249
+ },
250
+ body: JSON.stringify({
251
+ config_string: JSON.stringify(config),
252
+ source
253
+ })
254
+ });
255
+ if (response.ok) return;
256
+ const responseText = await response.text();
257
+ throw new CliError(`Failed to push config with STACK_SECRET_SERVER_KEY: ${responseText.length > 0 ? responseText : `Request failed with status ${response.status}.`}`);
258
+ }
259
+ function sourceToSdkSource(source) {
260
+ if (source.type === "pushed-from-github") return {
261
+ type: "pushed-from-github",
262
+ owner: source.owner,
263
+ repo: source.repo,
264
+ branch: source.branch,
265
+ commitHash: source.commit_hash,
266
+ configFilePath: source.config_file_path
267
+ };
268
+ if (source.type === "pushed-from-unknown") return { type: "pushed-from-unknown" };
269
+ return { type: "unlinked" };
270
+ }
198
271
  function registerConfigCommand(program) {
199
272
  const config = program.command("config").description("Manage project configuration files");
200
273
  config.command("pull").description("Pull branch config to a local file").requiredOption("--config-file <path>", "Path to write config file (.ts)").option("--overwrite", "Overwrite an existing config file").action(async (opts) => {
201
- const configOverride = await (await getAdminProject(resolveAuth(program.opts()))).getConfigOverride("branch");
274
+ const auth = resolveAuth(program.opts());
275
+ if (!isProjectAuthWithRefreshToken(auth)) throw new CliError("`stack config pull` requires `stack login`. Remove STACK_SECRET_SERVER_KEY and try again.");
276
+ const configOverride = await (await getAdminProject(auth)).getConfigOverride("branch");
202
277
  const filePath = path.resolve(opts.configFile);
203
278
  if (path.extname(filePath) !== ".ts") throw new CliError("Config file must have a .ts extension. Typed config files require TypeScript.");
204
279
  if (fs.existsSync(filePath) && !opts.overwrite) throw new CliError(`Config file already exists at ${filePath}. Stage or back up your changes, then re-run with --overwrite.`);
@@ -207,15 +282,20 @@ function registerConfigCommand(program) {
207
282
  console.log(`Config written to ${filePath}`);
208
283
  });
209
284
  config.command("push").description("Push a local config file to branch config").requiredOption("--config-file <path>", "Path to config file (.js or .ts)").action(async (opts) => {
210
- const project = await getAdminProject(resolveAuth(program.opts()));
285
+ const auth = resolveAuth(program.opts());
211
286
  const filePath = path.resolve(opts.configFile);
212
287
  const ext = path.extname(filePath);
213
288
  if (ext !== ".js" && ext !== ".ts") throw new CliError("Config file must have a .js or .ts extension.");
214
289
  if (!fs.existsSync(filePath)) throw new CliError(`Config file not found: ${filePath}`);
215
290
  const { createJiti } = await import("jiti");
216
291
  const config = (await createJiti(import.meta.url).import(filePath)).config;
217
- if (!isPlainObject(config)) throw new CliError(`Config file must export a plain \`config\` object. Example: import type { StackConfig } from "${detectImportPackageFromDir(path.dirname(filePath)) ?? "@stackframe/js"}"; export const config: StackConfig = { ... };`);
218
- await project.replaceConfigOverride("branch", config);
292
+ if (!isConfigOverride(config)) throw new CliError(`Config file must export a plain \`config\` object. Example: import type { StackConfig } from "${detectImportPackageFromDir(path.dirname(filePath)) ?? "@stackframe/js"}"; export const config: StackConfig = { ... };`);
293
+ const source = buildConfigPushSource(opts.configFile);
294
+ if (isProjectAuthWithSecretServerKey(auth)) await pushConfigWithSecretServerKey(auth, config, source);
295
+ else {
296
+ if (!isProjectAuthWithRefreshToken(auth)) throw new CliError("`stack config push` requires either STACK_SECRET_SERVER_KEY or `stack login`.");
297
+ await (await getAdminProject(auth)).pushConfig(config, { source: sourceToSdkSource(source) });
298
+ }
219
299
  console.log("Config pushed successfully.");
220
300
  });
221
301
  }
@@ -723,6 +803,60 @@ function registerProjectCommand(program) {
723
803
 
724
804
  //#endregion
725
805
  //#region src/commands/emulator.ts
806
+ const DEFAULT_EMULATOR_BACKEND_PORT = 26701;
807
+ function emulatorBackendPort() {
808
+ const raw = process.env.EMULATOR_BACKEND_PORT;
809
+ if (!raw) return DEFAULT_EMULATOR_BACKEND_PORT;
810
+ const parsed = Number(raw);
811
+ if (!Number.isInteger(parsed) || parsed <= 0) throw new CliError(`Invalid EMULATOR_BACKEND_PORT: ${raw}`);
812
+ return parsed;
813
+ }
814
+ function emulatorHome() {
815
+ return process.env.STACK_EMULATOR_HOME ?? join(homedir(), ".stack", "emulator");
816
+ }
817
+ function emulatorRunDir() {
818
+ return join(emulatorHome(), "run");
819
+ }
820
+ function emulatorImageDir() {
821
+ return join(emulatorHome(), "images");
822
+ }
823
+ function internalPckPath() {
824
+ return join(emulatorRunDir(), "vm", "internal-pck");
825
+ }
826
+ async function readInternalPck(timeoutMs = 6e4) {
827
+ const path = internalPckPath();
828
+ const deadline = Date.now() + timeoutMs;
829
+ let delay = 250;
830
+ while (Date.now() < deadline) {
831
+ if (existsSync(path)) {
832
+ const contents = readFileSync(path, "utf-8").trim();
833
+ if (contents) return contents;
834
+ }
835
+ await new Promise((r) => setTimeout(r, delay));
836
+ delay = Math.min(delay * 2, 2e3);
837
+ }
838
+ throw new CliError(`Timed out waiting for emulator internal publishable client key at ${path}`);
839
+ }
840
+ async function fetchEmulatorCredentials(pck, backendPort, configFile) {
841
+ const url = `http://127.0.0.1:${backendPort}/api/v1/internal/local-emulator/project`;
842
+ const res = await fetch(url, {
843
+ method: "POST",
844
+ headers: {
845
+ "Content-Type": "application/json",
846
+ "X-Stack-Project-Id": "internal",
847
+ "X-Stack-Access-Type": "client",
848
+ "X-Stack-Publishable-Client-Key": pck
849
+ },
850
+ body: JSON.stringify({ absolute_file_path: configFile })
851
+ });
852
+ if (!res.ok) throw new CliError(`Failed to initialize local emulator project (${res.status}): ${await res.text()}`);
853
+ const data = await res.json();
854
+ return {
855
+ project_id: data.project_id,
856
+ publishable_client_key: data.publishable_client_key,
857
+ secret_server_key: data.secret_server_key
858
+ };
859
+ }
726
860
  function gh(args) {
727
861
  try {
728
862
  return execFileSync("gh", args, {
@@ -738,28 +872,57 @@ function gh(args) {
738
872
  throw new CliError("GitHub CLI (gh) is required. Install: https://cli.github.com/");
739
873
  }
740
874
  }
741
- function findQemuDir() {
742
- for (const rel of ["docker/local-emulator/qemu", "../docker/local-emulator/qemu"]) {
743
- const dir = resolve(process.cwd(), rel);
744
- if (existsSync(join(dir, "run-emulator.sh"))) return dir;
745
- }
746
- throw new CliError("Could not find QEMU emulator directory. Run this from the stack-auth repo root.");
875
+ function emulatorScriptsDir() {
876
+ const here = dirname(fileURLToPath(import.meta.url));
877
+ const bundled = join(here, "emulator");
878
+ if (existsSync(join(bundled, "run-emulator.sh"))) return bundled;
879
+ const repo = resolve(here, "../../../docker/local-emulator/qemu");
880
+ if (existsSync(join(repo, "run-emulator.sh"))) return repo;
881
+ throw new CliError("Emulator scripts not found in CLI bundle.");
882
+ }
883
+ function emulatorSpawnEnv(extra) {
884
+ return {
885
+ ...process.env,
886
+ EMULATOR_RUN_DIR: emulatorRunDir(),
887
+ EMULATOR_IMAGE_DIR: emulatorImageDir(),
888
+ ...extra
889
+ };
747
890
  }
748
891
  function runEmulator(action, env) {
749
- const qemuDir = findQemuDir();
750
- return new Promise((resolve, reject) => {
751
- const child = spawn(join(qemuDir, "run-emulator.sh"), [action], {
892
+ const scriptsDir = emulatorScriptsDir();
893
+ mkdirSync(emulatorRunDir(), { recursive: true });
894
+ mkdirSync(emulatorImageDir(), { recursive: true });
895
+ return new Promise((resolvePromise, reject) => {
896
+ const child = spawn(join(scriptsDir, "run-emulator.sh"), [action], {
752
897
  stdio: "inherit",
753
- env: {
754
- ...process.env,
755
- ...env
756
- },
757
- cwd: qemuDir
898
+ env: emulatorSpawnEnv(env),
899
+ cwd: scriptsDir
758
900
  });
759
- child.on("close", (code) => code === 0 ? resolve() : reject(new CliError(`run-emulator.sh ${action} exited with code ${code}`)));
901
+ child.on("close", (code) => code === 0 ? resolvePromise() : reject(new CliError(`run-emulator.sh ${action} exited with code ${code}`)));
760
902
  child.on("error", (err) => reject(new CliError(`Failed to run run-emulator.sh: ${err.message}`)));
761
903
  });
762
904
  }
905
+ function isEmulatorRunning() {
906
+ const scriptsDir = emulatorScriptsDir();
907
+ try {
908
+ execFileSync(join(scriptsDir, "run-emulator.sh"), ["status"], {
909
+ stdio: "pipe",
910
+ cwd: scriptsDir,
911
+ env: emulatorSpawnEnv()
912
+ });
913
+ return true;
914
+ } catch {
915
+ return false;
916
+ }
917
+ }
918
+ async function startEmulator(arch) {
919
+ mkdirSync(emulatorImageDir(), { recursive: true });
920
+ if (!existsSync(join(emulatorImageDir(), `stack-emulator-${arch}.qcow2`))) {
921
+ console.log("No emulator image found. Pulling latest...");
922
+ pullRelease(arch);
923
+ }
924
+ await runEmulator("start", { EMULATOR_ARCH: arch });
925
+ }
763
926
  function resolveArch(raw) {
764
927
  const arch = raw ?? (process.arch === "arm64" ? "arm64" : process.arch === "x64" ? "amd64" : null);
765
928
  if (arch === "arm64" || arch === "amd64") return arch;
@@ -770,7 +933,7 @@ function pullRelease(arch, opts = {}) {
770
933
  const branch = opts.branch ?? "dev";
771
934
  const tag = opts.tag ?? `emulator-${branch}-latest`;
772
935
  const asset = `stack-emulator-${arch}.qcow2`;
773
- const imageDir = join(findQemuDir(), "images");
936
+ const imageDir = emulatorImageDir();
774
937
  mkdirSync(imageDir, { recursive: true });
775
938
  const dest = join(imageDir, asset);
776
939
  const tmpDest = `${dest}.download`;
@@ -832,7 +995,7 @@ function registerEmulatorCommand(program) {
832
995
  if (runs.length === 0) throw new CliError(`No successful build found for PR #${opts.pr} (branch: ${headRefName}).`);
833
996
  runId = String(runs[0].databaseId);
834
997
  }
835
- const imageDir = join(findQemuDir(), "images");
998
+ const imageDir = emulatorImageDir();
836
999
  mkdirSync(imageDir, { recursive: true });
837
1000
  const dest = join(imageDir, `stack-emulator-${arch}.qcow2`);
838
1001
  if (existsSync(dest)) unlinkSync(dest);
@@ -860,13 +1023,64 @@ function registerEmulatorCommand(program) {
860
1023
  tag: opts.tag
861
1024
  });
862
1025
  });
863
- emulator.command("start").description("Start the emulator in the background (auto-pulls the latest image if none exists)").option("--arch <arch>", "Target architecture (default: current system arch). Non-native uses software emulation and is significantly slower.").action(async (opts) => {
1026
+ emulator.command("start").description("Start the emulator in the background (auto-pulls the latest image if none exists)").option("--arch <arch>", "Target architecture (default: current system arch). Non-native uses software emulation and is significantly slower.").option("--config-file <path>", "Path to a config file; when set, credentials for this project are printed to stdout as JSON").action(async (opts) => {
864
1027
  const arch = resolveArch(opts.arch);
865
- if (!existsSync(join(findQemuDir(), "images", `stack-emulator-${arch}.qcow2`))) {
866
- console.log("No emulator image found. Pulling latest...");
867
- pullRelease(arch);
1028
+ let resolvedConfigFile;
1029
+ if (opts.configFile) {
1030
+ resolvedConfigFile = resolve(opts.configFile);
1031
+ if (!existsSync(resolvedConfigFile)) throw new CliError(`Config file not found: ${resolvedConfigFile}`);
868
1032
  }
869
- await runEmulator("start", { EMULATOR_ARCH: arch });
1033
+ if (isEmulatorRunning()) console.warn("Emulator already running, reusing existing instance.");
1034
+ else await startEmulator(arch);
1035
+ if (resolvedConfigFile) {
1036
+ const creds = await fetchEmulatorCredentials(await readInternalPck(), emulatorBackendPort(), resolvedConfigFile);
1037
+ console.log(JSON.stringify(creds, null, 2));
1038
+ }
1039
+ });
1040
+ emulator.command("run").description("Start the emulator, run a command, and stop the emulator when the command exits").argument("<cmd>", "Command to run (e.g. \"npm run dev\")").option("--arch <arch>", "Target architecture").option("--config-file <path>", "Path to a config file; fetches credentials and injects STACK_PROJECT_ID / STACK_PUBLISHABLE_CLIENT_KEY / STACK_SECRET_SERVER_KEY into the child").action(async (cmd, opts) => {
1041
+ const arch = resolveArch(opts.arch);
1042
+ let resolvedConfigFile;
1043
+ if (opts.configFile) {
1044
+ resolvedConfigFile = resolve(opts.configFile);
1045
+ if (!existsSync(resolvedConfigFile)) throw new CliError(`Config file not found: ${resolvedConfigFile}`);
1046
+ }
1047
+ const alreadyRunning = isEmulatorRunning();
1048
+ if (alreadyRunning) console.log("Emulator already running, reusing existing instance.");
1049
+ else await startEmulator(arch);
1050
+ const childEnv = { ...process.env };
1051
+ if (resolvedConfigFile) {
1052
+ const pck = await readInternalPck();
1053
+ const backendPort = emulatorBackendPort();
1054
+ const creds = await fetchEmulatorCredentials(pck, backendPort, resolvedConfigFile);
1055
+ const apiUrl = `http://127.0.0.1:${backendPort}`;
1056
+ childEnv.STACK_PROJECT_ID = creds.project_id;
1057
+ childEnv.NEXT_PUBLIC_STACK_PROJECT_ID = creds.project_id;
1058
+ childEnv.STACK_PUBLISHABLE_CLIENT_KEY = creds.publishable_client_key;
1059
+ childEnv.NEXT_PUBLIC_STACK_PUBLISHABLE_CLIENT_KEY = creds.publishable_client_key;
1060
+ childEnv.STACK_SECRET_SERVER_KEY = creds.secret_server_key;
1061
+ childEnv.STACK_API_URL = apiUrl;
1062
+ childEnv.NEXT_PUBLIC_STACK_API_URL = apiUrl;
1063
+ }
1064
+ const child = spawn(cmd, {
1065
+ shell: true,
1066
+ stdio: "inherit",
1067
+ env: childEnv
1068
+ });
1069
+ const forward = (signal) => () => child.kill(signal);
1070
+ const onSigint = forward("SIGINT");
1071
+ const onSigterm = forward("SIGTERM");
1072
+ process.on("SIGINT", onSigint);
1073
+ process.on("SIGTERM", onSigterm);
1074
+ child.on("close", (code) => {
1075
+ process.off("SIGINT", onSigint);
1076
+ process.off("SIGTERM", onSigterm);
1077
+ const exitCode = code ?? 1;
1078
+ if (alreadyRunning) process.exit(exitCode);
1079
+ else {
1080
+ console.log("\nStopping emulator...");
1081
+ runEmulator("stop").catch(() => {}).finally(() => process.exit(exitCode));
1082
+ }
1083
+ });
870
1084
  });
871
1085
  emulator.command("stop").description("Stop the emulator (data preserved; use 'reset' to clear)").action(() => runEmulator("stop"));
872
1086
  emulator.command("reset").description("Reset emulator state for a fresh boot").action(() => runEmulator("reset"));