@whop/cli 0.3.0 → 0.5.0

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
@@ -5,9 +5,11 @@ import {
5
5
  Cli_exports,
6
6
  Identity,
7
7
  OAuthError,
8
+ Openapi_exports,
8
9
  Profile,
9
10
  PromptCancelledError,
10
11
  apiBaseUrl,
12
+ apps_spec_default,
11
13
  buildTarget,
12
14
  buildVersion,
13
15
  chooseAuthMethod,
@@ -33,7 +35,7 @@ import {
33
35
  switchProfile,
34
36
  upsertProfile,
35
37
  validateApiKey
36
- } from "./chunk-4WNH276N.js";
38
+ } from "./chunk-X34EUMEM.js";
37
39
  import {
38
40
  external_exports
39
41
  } from "./chunk-KFCNNWPI.js";
@@ -120,6 +122,10 @@ var groups_default = [
120
122
  "audiences",
121
123
  "Audiences"
122
124
  ],
125
+ [
126
+ "media",
127
+ "Media"
128
+ ],
123
129
  [
124
130
  "social-accounts",
125
131
  "Social Accounts"
@@ -312,7 +318,7 @@ async function loginAdapter(c2) {
312
318
  const accountId = getActiveProfile()?.accountId ?? "";
313
319
  if (accountId) {
314
320
  try {
315
- const { createWhopFetch: createWhopFetch2 } = await import("./api-A6NYSDKW.js");
321
+ const { createWhopFetch: createWhopFetch2 } = await import("./api-E6Y2WAEG.js");
316
322
  const fetch3 = createWhopFetch2();
317
323
  const res = await fetch3(
318
324
  new Request(
@@ -592,11 +598,11 @@ function buildAuthGroup() {
592
598
  }
593
599
 
594
600
  // src/apps/commands.ts
595
- import { isCancel, select, text } from "@clack/prompts";
601
+ import { isCancel, select, spinner, text } from "@clack/prompts";
596
602
  import { spawnSync as spawnSync2 } from "child_process";
597
603
  import { createHash } from "crypto";
598
604
  import { existsSync as existsSync3, readFileSync as readFileSync3, readdirSync } from "fs";
599
- import { join as join3, resolve as resolve2 } from "path";
605
+ import { join as join3, relative, resolve as resolve2 } from "path";
600
606
  import chalk from "chalk";
601
607
 
602
608
  // src/journey/client.ts
@@ -642,6 +648,9 @@ async function getApp(appId) {
642
648
  async function updateAppRoute(appId, route) {
643
649
  return makeWhopRequest("PATCH", `/apps/${appId}`, { route });
644
650
  }
651
+ async function updateAppSecrets(appId, secrets) {
652
+ return makeWhopRequest("PATCH", `/apps/${appId}`, { secrets });
653
+ }
645
654
  async function createAppBuild(input) {
646
655
  return makeWhopRequest("POST", "/app_builds", {
647
656
  app_id: input.app_id,
@@ -653,13 +662,6 @@ async function createAppBuild(input) {
653
662
  async function getAppBuild(buildId) {
654
663
  return makeWhopRequest("GET", `/app_builds/${buildId}`);
655
664
  }
656
- async function listAppBuilds(appId) {
657
- const response = await makeWhopRequest(
658
- "GET",
659
- `/app_builds?app_id=${encodeURIComponent(appId)}&platform=web`
660
- );
661
- return response.data ?? [];
662
- }
663
665
  async function promoteAppBuild(buildId) {
664
666
  return makeWhopRequest(
665
667
  "POST",
@@ -767,7 +769,11 @@ var DLX_RUNNERS = {
767
769
  bun: ["bunx"],
768
770
  yarn: ["yarn", "dlx"]
769
771
  };
770
- var PM_PRIORITY = ["bun", "pnpm", "npm"];
772
+ var PM_PRIORITY = [
773
+ "bun",
774
+ "pnpm",
775
+ "npm"
776
+ ];
771
777
  function isPmInstalled(pm) {
772
778
  const result = spawnSync(pm, ["--version"], {
773
779
  stdio: "ignore",
@@ -791,6 +797,34 @@ function pmInstallHint(pm) {
791
797
  return "npm install -g yarn";
792
798
  }
793
799
  }
800
+ var TANSTACK_CREATE_FLAGS = [
801
+ "--framework",
802
+ "React",
803
+ "--deployment",
804
+ "cloudflare",
805
+ "--no-git",
806
+ "--no-install",
807
+ "--no-intent",
808
+ "--no-toolchain",
809
+ "--no-examples",
810
+ "--non-interactive"
811
+ ];
812
+ function tanstackCreateCommand(pm, projectName) {
813
+ return [
814
+ ...DLX_RUNNERS[pm],
815
+ "@tanstack/cli@latest",
816
+ "create",
817
+ projectName,
818
+ ...TANSTACK_CREATE_FLAGS
819
+ ].join(" ");
820
+ }
821
+ function outputTail(result) {
822
+ const combined = [result.stdout, result.stderr].filter(Boolean).join("\n").trim();
823
+ if (!combined) return "";
824
+ return `
825
+
826
+ ${combined.split("\n").slice(-15).join("\n")}`;
827
+ }
794
828
  function runTanstackCreate(options) {
795
829
  const [runner, ...runnerArgs] = DLX_RUNNERS[options.pm];
796
830
  const result = spawnSync(
@@ -802,26 +836,18 @@ function runTanstackCreate(options) {
802
836
  options.projectName,
803
837
  "--target-dir",
804
838
  options.targetDir,
805
- "--framework",
806
- "React",
807
- "--deployment",
808
- "cloudflare",
809
- "--no-git",
810
- "--no-install",
811
- "--no-intent",
812
- "--no-toolchain",
813
- "--no-examples",
814
- "--non-interactive"
839
+ ...TANSTACK_CREATE_FLAGS
815
840
  ],
816
841
  {
817
- stdio: options.quiet ? "ignore" : "inherit",
842
+ stdio: ["ignore", "pipe", "pipe"],
843
+ encoding: "utf-8",
818
844
  shell: SPAWN_VIA_SHELL
819
845
  }
820
846
  );
821
847
  if (result.error) throw result.error;
822
848
  if (result.status !== 0) {
823
849
  throw new Error(
824
- `\`${options.pm === "npm" ? "npx" : options.pm} @tanstack/cli create\` failed (exit ${result.status}). Check your network connection and try again.`
850
+ `\`${options.pm === "npm" ? "npx" : options.pm} @tanstack/cli create\` failed (exit ${result.status}). Check your network connection and try again.${outputTail(result)}`
825
851
  );
826
852
  }
827
853
  }
@@ -831,6 +857,11 @@ var VITE_CONFIG_NAMES = [
831
857
  "vite.config.js",
832
858
  "vite.config.mjs"
833
859
  ];
860
+ function findViteConfig(dir) {
861
+ return VITE_CONFIG_NAMES.map((name) => join2(dir, name)).find(
862
+ (path3) => existsSync2(path3)
863
+ );
864
+ }
834
865
  var WRANGLER_CONFIG_NAMES = ["wrangler.jsonc", "wrangler.json"];
835
866
  function cliDependencyVersion() {
836
867
  return buildVersion ? `^${buildVersion}` : "latest";
@@ -867,9 +898,7 @@ function patchPackageJson(dir, manualSteps) {
867
898
  `);
868
899
  }
869
900
  function patchViteConfig(dir, manualSteps) {
870
- const file = VITE_CONFIG_NAMES.map((name) => join2(dir, name)).find(
871
- (path3) => existsSync2(path3)
872
- );
901
+ const file = findViteConfig(dir);
873
902
  if (!file) {
874
903
  manualSteps.push(
875
904
  "No vite config found (looked for vite.config.ts/.mts/.js/.mjs). Whop hosting serves Vite builds \u2014 create a vite config that builds with @cloudflare/vite-plugin (emitting dist/client and, for SSR, dist/server) and includes the whop() plugin from @whop/cli/vite in its plugins array."
@@ -962,26 +991,36 @@ function replaceTopLevelName(content, route) {
962
991
 
963
992
  // src/apps/commands.ts
964
993
  var BUILD_ARCHIVE = "dist/whop-build.zip";
965
- var AppSchema = external_exports.object({
966
- id: external_exports.string(),
967
- name: external_exports.string(),
968
- route: external_exports.string().nullable().optional(),
969
- url: external_exports.string().optional(),
970
- directory: external_exports.string().optional(),
971
- install_failed: external_exports.boolean().optional()
972
- });
994
+ var UNSAFE_DEV_ENV_KEYS = /* @__PURE__ */ new Set([
995
+ "PATH",
996
+ "NODE_OPTIONS",
997
+ "NODE_EXTRA_CA_CERTS",
998
+ "NODE_REPL_EXTERNAL_MODULE"
999
+ ]);
1000
+ var UNSAFE_DEV_ENV_PREFIXES = ["NPM_", "PNPM_", "YARN_", "BUN_", "LD_", "DYLD_"];
1001
+ function isUnsafeDevEnvKey(key) {
1002
+ const upper = key.toUpperCase();
1003
+ return UNSAFE_DEV_ENV_KEYS.has(upper) || UNSAFE_DEV_ENV_PREFIXES.some((prefix) => upper.startsWith(prefix));
1004
+ }
973
1005
  var BuildSchema = external_exports.object({
974
1006
  id: external_exports.string(),
975
1007
  status: external_exports.string(),
976
1008
  is_production: external_exports.boolean().optional(),
977
1009
  url: external_exports.string().optional()
978
1010
  });
979
- function detectPackageManager2(projectDir) {
1011
+ var DeployAbort = class extends Error {
1012
+ constructor(options) {
1013
+ super(options.message);
1014
+ this.options = options;
1015
+ }
1016
+ };
1017
+ function detectProjectPackageManager(projectDir) {
980
1018
  if (existsSync3(join3(projectDir, "bun.lock")) || existsSync3(join3(projectDir, "bun.lockb")))
981
1019
  return "bun";
982
1020
  if (existsSync3(join3(projectDir, "pnpm-lock.yaml"))) return "pnpm";
983
1021
  if (existsSync3(join3(projectDir, "yarn.lock"))) return "yarn";
984
- return "npm";
1022
+ if (existsSync3(join3(projectDir, "package-lock.json"))) return "npm";
1023
+ return detectPackageManager() ?? "npm";
985
1024
  }
986
1025
  function hasScript(projectDir, script) {
987
1026
  try {
@@ -995,7 +1034,7 @@ function hasScript(projectDir, script) {
995
1034
  }
996
1035
  var SPAWN_VIA_SHELL2 = process.platform === "win32";
997
1036
  function runScript(projectDir, script, extraEnv = {}) {
998
- const pm = detectPackageManager2(projectDir);
1037
+ const pm = detectProjectPackageManager(projectDir);
999
1038
  const result = spawnSync2(pm, ["run", script], {
1000
1039
  cwd: projectDir,
1001
1040
  stdio: "inherit",
@@ -1013,501 +1052,413 @@ function runScript(projectDir, script, extraEnv = {}) {
1013
1052
  );
1014
1053
  }
1015
1054
  }
1055
+ function runInstall(c2, projectDir) {
1056
+ const pm = detectProjectPackageManager(projectDir);
1057
+ const spin = c2.agent ? null : spinner();
1058
+ spin?.start(`Installing dependencies with ${pm}`);
1059
+ const result = spawnSync2(pm, ["install"], {
1060
+ cwd: projectDir,
1061
+ stdio: ["ignore", "pipe", "pipe"],
1062
+ encoding: "utf-8",
1063
+ shell: SPAWN_VIA_SHELL2
1064
+ });
1065
+ if (result.error || result.status !== 0) {
1066
+ spin?.error(`${pm} install failed`);
1067
+ throw new Error(
1068
+ `\`${pm} install\` failed${result.status != null ? ` with exit code ${result.status}` : ""} \u2014 run it manually inside ${projectDir}, then re-run \`whop apps deploy\`.${outputTail(result)}`
1069
+ );
1070
+ }
1071
+ spin?.stop(`Installed dependencies with ${pm}`);
1072
+ }
1016
1073
  function log(c2, message) {
1017
1074
  if (!c2.agent) console.log(message);
1018
1075
  }
1019
- function loadProject(dir) {
1020
- const projectDir = dir ? resolve2(dir) : findProjectDir();
1021
- if (!projectDir) {
1022
- return {
1023
- ok: false,
1024
- error: {
1025
- code: "NO_PROJECT",
1026
- message: `No ${APP_CONFIG_FILENAME} found in this directory or any parent. Run \`whop apps create --existing\` to link this project to a Whop app.`,
1027
- cta: {
1028
- commands: [
1029
- {
1030
- command: "apps create --existing",
1031
- description: "Link this project to a Whop app"
1032
- }
1033
- ]
1034
- }
1035
- }
1036
- };
1037
- }
1038
- const config = readAppConfig(projectDir);
1039
- if (!config) {
1040
- return {
1041
- ok: false,
1042
- error: {
1043
- code: "INVALID_CONFIG",
1044
- message: `${join3(projectDir, APP_CONFIG_FILENAME)} is missing or malformed. Run \`whop apps create --existing\` to regenerate it.`
1045
- }
1046
- };
1047
- }
1048
- return { ok: true, projectDir, config };
1049
- }
1050
1076
  async function promptText(message, placeholder, initialValue) {
1051
1077
  const value = await text({ message, placeholder, initialValue });
1052
1078
  if (isCancel(value)) return null;
1053
1079
  return String(value);
1054
1080
  }
1081
+ function cancelled() {
1082
+ throw new DeployAbort({
1083
+ code: "CANCELLED",
1084
+ message: "Cancelled.",
1085
+ exitCode: 130
1086
+ });
1087
+ }
1055
1088
  function formatManualSteps(steps) {
1056
1089
  return steps.map((step, i) => ` ${i + 1}. ${step}`).join("\n");
1057
1090
  }
1058
- async function runRegisterFlow(c2, options) {
1059
- const projectDir = resolve2(options.dir ?? ".");
1060
- if (!existsSync3(join3(projectDir, "package.json"))) {
1061
- return c2.error({
1062
- code: "NO_PACKAGE_JSON",
1063
- message: `No package.json in ${projectDir} \u2014 --existing wires up a project that's already here. Drop --existing to scaffold a new one.`
1091
+ function formatAppList(apps) {
1092
+ const recent = [...apps].sort((a, b) => (b.created_at ?? 0) - (a.created_at ?? 0)).slice(0, 10);
1093
+ return recent.map(
1094
+ (a) => ` ${a.id} ${a.name}${a.route ? ` (${a.route}.whop.app)` : ""}`
1095
+ ).join("\n");
1096
+ }
1097
+ async function registerSpecCommands(group, tag) {
1098
+ const spec2 = filterSpecByTag(apps_spec_default, tag);
1099
+ const generated = await Openapi_exports.generateCommands(
1100
+ spec2,
1101
+ createWhopFetch()
1102
+ );
1103
+ for (const [name, entry] of generated) {
1104
+ if ("run" in entry) group.command(name, entry);
1105
+ }
1106
+ }
1107
+ function ensurePackageManager() {
1108
+ const pm = detectPackageManager();
1109
+ if (pm) return pm;
1110
+ throw new DeployAbort({
1111
+ code: "NO_PACKAGE_MANAGER",
1112
+ message: `No package manager found (looked for bun, pnpm, npm). Install one and re-run \`whop apps deploy\`:
1113
+
1114
+ bun (recommended): ${pmInstallHint("bun")}
1115
+ pnpm: ${pmInstallHint("pnpm")}
1116
+ npm: ${pmInstallHint("npm")}`
1117
+ });
1118
+ }
1119
+ async function ensureViteApp(c2, pm, dirOption) {
1120
+ const projectDir = dirOption ? resolve2(dirOption) : findProjectDir() ?? process.cwd();
1121
+ const isViteApp = existsSync3(join3(projectDir, "package.json")) && findViteConfig(projectDir) !== void 0;
1122
+ if (isViteApp) return { projectDir, freshlyScaffolded: false };
1123
+ if (c2.agent) {
1124
+ throw new DeployAbort({
1125
+ code: "NOT_A_VITE_APP",
1126
+ message: [
1127
+ `${projectDir} doesn't look like a Vite app (missing package.json or vite config).`,
1128
+ "",
1129
+ "Ask the user whether to scaffold a new app here, or locate their existing Vite app.",
1130
+ "",
1131
+ "To scaffold a new app (TanStack Start), run:",
1132
+ ` ${tanstackCreateCommand(pm, "<app-name>")}`,
1133
+ ` cd <app-name> && ${pm} install`,
1134
+ "",
1135
+ "Then re-run `whop apps deploy` from inside the app directory.",
1136
+ "Or, if a Vite app already exists somewhere else, re-run `whop apps deploy --dir <path-to-app>`."
1137
+ ].join("\n"),
1138
+ retryable: true,
1139
+ cta: {
1140
+ commands: [
1141
+ {
1142
+ command: "apps deploy",
1143
+ description: "Re-run from inside a Vite app directory"
1144
+ }
1145
+ ]
1146
+ }
1064
1147
  });
1065
1148
  }
1066
- const existing = readAppConfig(projectDir);
1067
- if (existing) {
1068
- const { manualSteps: manualSteps2 } = whopifyProject({
1069
- targetDir: projectDir,
1070
- route: existing.route
1149
+ const choice = await select({
1150
+ message: `${projectDir} doesn't look like a Vite app. What do you want to do?`,
1151
+ options: [
1152
+ {
1153
+ value: "scaffold",
1154
+ label: "Scaffold a new Whop app here (TanStack Start)"
1155
+ },
1156
+ { value: "cancel", label: "Cancel \u2014 I'll cd into my app directory" }
1157
+ ]
1158
+ });
1159
+ if (isCancel(choice) || choice === "cancel") cancelled();
1160
+ const scaffolded = await scaffoldNewApp(c2, pm);
1161
+ return { projectDir: scaffolded, freshlyScaffolded: true };
1162
+ }
1163
+ async function scaffoldNewApp(c2, pm) {
1164
+ const name = await promptText("What is your app called?", "My Site");
1165
+ if (name === null) cancelled();
1166
+ const slug = slugify(name);
1167
+ const routeInput = await promptText(
1168
+ "Which route should it live at? (your-route.whop.app)",
1169
+ slug,
1170
+ slug
1171
+ );
1172
+ if (routeInput === null) cancelled();
1173
+ const route = slugify(routeInput);
1174
+ if (!route) {
1175
+ throw new DeployAbort({
1176
+ code: "ROUTE_REQUIRED",
1177
+ message: `Couldn't derive a route from "${routeInput}" \u2014 use letters and numbers (e.g. my-site).`,
1178
+ retryable: true
1071
1179
  });
1072
- if (manualSteps2.length > 0) {
1073
- return c2.error({
1074
- code: "WHOPIFY_INCOMPLETE",
1075
- message: `This project is already linked to ${existing.app_id} (${existing.route}), but it isn't fully wired for Whop hosting yet. Complete these steps:
1076
-
1077
- ${formatManualSteps(manualSteps2)}
1180
+ }
1181
+ const targetDir = resolve2(`./${route}`);
1182
+ if (existsSync3(targetDir) && readdirSync(targetDir).length > 0) {
1183
+ throw new DeployAbort({
1184
+ code: "TARGET_NOT_EMPTY",
1185
+ message: `Target directory is not empty: ${targetDir}. Pick a different route, or run \`whop apps deploy\` from an empty directory.`
1186
+ });
1187
+ }
1188
+ let app;
1189
+ try {
1190
+ app = await createApp({ name, route });
1191
+ } catch (err) {
1192
+ throw new DeployAbort({
1193
+ code: "CREATE_FAILED",
1194
+ message: err instanceof Error ? err.message : "Failed to register the app",
1195
+ retryable: true
1196
+ });
1197
+ }
1198
+ const spin = c2.agent ? null : spinner();
1199
+ spin?.start("Scaffolding the latest TanStack Start template");
1200
+ try {
1201
+ runTanstackCreate({ targetDir, projectName: route, pm });
1202
+ spin?.stop(`Scaffolded ${route} from the latest TanStack Start template`);
1203
+ } catch (err) {
1204
+ spin?.error("Scaffold failed");
1205
+ throw new DeployAbort({
1206
+ code: "SCAFFOLD_FAILED",
1207
+ message: `${err instanceof Error ? err.message : "Failed to scaffold the project"}
1078
1208
 
1079
- Then run \`whop apps deploy\`. Re-running \`whop apps create --existing\` re-checks the wiring.`
1080
- });
1081
- }
1082
- if (!c2.agent && !c2.formatExplicit) {
1083
- console.log(
1084
- `${chalk.green.bold(`\u2713 Already linked to ${existing.name}`)} ${chalk.dim(`(${existing.app_id})`)} \u2014 wiring verified. Delete ${APP_CONFIG_FILENAME} to relink.`
1085
- );
1086
- }
1087
- return c2.ok({
1088
- id: existing.app_id,
1089
- name: existing.name,
1090
- route: existing.route,
1091
- directory: projectDir
1209
+ The app ${app.id} is already registered \u2014 scaffold into a fresh directory and re-run \`whop apps deploy --app ${app.id}\` there.`
1092
1210
  });
1093
1211
  }
1094
- let linked = null;
1095
- if (options.link) {
1096
- const apps = await listApps();
1097
- linked = apps.find((a) => a.id === options.link) ?? null;
1098
- if (!linked) {
1099
- return c2.error({
1212
+ writeAppConfig(targetDir, { app_id: app.id, name, route });
1213
+ return targetDir;
1214
+ }
1215
+ async function ensureAppLinked(c2, projectDir, appOption) {
1216
+ if (appOption) {
1217
+ let app;
1218
+ try {
1219
+ app = await getApp(appOption);
1220
+ } catch (err) {
1221
+ throw new DeployAbort({
1100
1222
  code: "APP_NOT_FOUND",
1101
- message: `No app ${options.link} found on your account.`
1223
+ message: `Could not load app ${appOption}: ${err instanceof Error ? err.message : "unknown error"}`,
1224
+ retryable: true
1102
1225
  });
1103
1226
  }
1227
+ const config2 = await withRoute(c2, app);
1228
+ writeAppConfig(projectDir, config2);
1229
+ log(
1230
+ c2,
1231
+ chalk.dim(
1232
+ `Linked ${config2.name} (${config2.app_id}) \u2192 ${APP_CONFIG_FILENAME}`
1233
+ )
1234
+ );
1235
+ return config2;
1104
1236
  }
1105
- let name = options.name ?? linked?.name;
1106
- if (!name) {
1107
- if (c2.agent) {
1108
- return c2.error({
1109
- code: "NAME_REQUIRED",
1110
- message: 'Pass a name: `whop apps create "My Site"`.',
1111
- retryable: true
1112
- });
1237
+ const existing = readAppConfig(projectDir);
1238
+ if (existing) return existing;
1239
+ const apps = await listApps();
1240
+ if (c2.agent) {
1241
+ const reuse = apps.length > 0 ? `This account has existing apps \u2014 ask the user whether to reuse one:
1242
+ ${formatAppList(apps)}
1243
+
1244
+ To reuse one: whop apps deploy --app <app_id>
1245
+
1246
+ ` : "This account has no apps yet, so a new one must be created.\n\n";
1247
+ throw new DeployAbort({
1248
+ code: "NO_APP_LINKED",
1249
+ message: [
1250
+ `No ${APP_CONFIG_FILENAME} here \u2014 this project isn't linked to a Whop app yet.`,
1251
+ "",
1252
+ `${reuse}To create a new app, ask the user for a name and route (the route becomes <route>.whop.app), then run:`,
1253
+ ' whop apps create --name "My Site" --route my-site',
1254
+ "",
1255
+ "Then deploy with the returned app id:",
1256
+ " whop apps deploy --app <app_id>"
1257
+ ].join("\n"),
1258
+ retryable: true,
1259
+ cta: {
1260
+ commands: [
1261
+ {
1262
+ command: "apps create",
1263
+ description: "Create a new app (pass --name and --route)"
1264
+ },
1265
+ {
1266
+ command: "apps deploy",
1267
+ description: "Re-run with --app <app_id> to link and deploy"
1268
+ }
1269
+ ]
1270
+ }
1271
+ });
1272
+ }
1273
+ let selected = null;
1274
+ if (apps.length > 0) {
1275
+ const recent = [...apps].sort(
1276
+ (a, b) => (b.created_at ?? 0) - (a.created_at ?? 0)
1277
+ );
1278
+ const choice = await select({
1279
+ message: "Which app should this project deploy to?",
1280
+ options: [
1281
+ ...recent.map((a) => ({
1282
+ value: a.id,
1283
+ label: `${a.name} (${a.id}${a.route ? `, ${a.route}.whop.app` : ""})`
1284
+ })),
1285
+ { value: "__new__", label: "Create a new app" }
1286
+ ]
1287
+ });
1288
+ if (isCancel(choice)) cancelled();
1289
+ if (choice !== "__new__") {
1290
+ selected = recent.find((a) => a.id === choice) ?? null;
1113
1291
  }
1114
- const value = await promptText("What is your app called?", "My Site");
1115
- if (value === null)
1116
- return c2.error({
1117
- code: "CANCELLED",
1118
- message: "Cancelled.",
1119
- exitCode: 130
1120
- });
1121
- name = value;
1122
1292
  }
1123
- if (!linked) {
1124
- const apps = await listApps();
1293
+ if (!selected) {
1294
+ const name = await promptText("What is your app called?", "My Site");
1295
+ if (name === null) cancelled();
1125
1296
  const slug = slugify(name);
1126
- const similar = apps.filter(
1127
- (a) => slugify(a.name) === slug || a.route === (options.route ?? slug)
1297
+ const routeInput = await promptText(
1298
+ "Which route should it live at? (your-route.whop.app)",
1299
+ slug,
1300
+ slug
1128
1301
  );
1129
- if (similar.length > 0 && !c2.agent) {
1130
- const choice = await select({
1131
- message: `Found existing app${similar.length > 1 ? "s" : ""} with a similar name \u2014 link one or create new?`,
1132
- options: [
1133
- ...similar.map((a) => ({
1134
- value: a.id,
1135
- label: `Link ${a.name} (${a.id}${a.route ? `, ${a.route}` : ""})`
1136
- })),
1137
- { value: "__new__", label: `Create a new app called "${name}"` }
1138
- ]
1302
+ if (routeInput === null) cancelled();
1303
+ const route = slugify(routeInput);
1304
+ if (!route) {
1305
+ throw new DeployAbort({
1306
+ code: "ROUTE_REQUIRED",
1307
+ message: `Couldn't derive a route from "${routeInput}" \u2014 use letters and numbers (e.g. my-site).`,
1308
+ retryable: true
1139
1309
  });
1140
- if (isCancel(choice))
1141
- return c2.error({
1142
- code: "CANCELLED",
1143
- message: "Cancelled.",
1144
- exitCode: 130
1145
- });
1146
- if (choice !== "__new__") {
1147
- linked = similar.find((a) => a.id === choice) ?? null;
1148
- }
1149
- } else if (similar.length > 0 && c2.agent) {
1150
- return c2.error({
1151
- code: "SIMILAR_APPS_FOUND",
1152
- message: `Existing app(s) with a similar name: ${similar.map((a) => `${a.name} (${a.id})`).join(
1153
- ", "
1154
- )}. Pass --link <app_id> to link one, or --name with a distinct name to create new.`,
1310
+ }
1311
+ try {
1312
+ selected = await createApp({ name, route });
1313
+ } catch (err) {
1314
+ throw new DeployAbort({
1315
+ code: "CREATE_FAILED",
1316
+ message: err instanceof Error ? err.message : "Failed to register the app",
1155
1317
  retryable: true
1156
1318
  });
1157
1319
  }
1158
1320
  }
1159
- let route = options.route ?? slugify(name);
1160
- if (!options.route && !linked && !c2.agent) {
1161
- const value = await promptText(
1162
- "Which route should it live at? (your-route.whop.app)",
1163
- slugify(name),
1164
- slugify(name)
1165
- );
1166
- if (value === null)
1167
- return c2.error({
1168
- code: "CANCELLED",
1169
- message: "Cancelled.",
1170
- exitCode: 130
1171
- });
1172
- route = slugify(value);
1321
+ const config = await withRoute(c2, selected);
1322
+ writeAppConfig(projectDir, config);
1323
+ log(
1324
+ c2,
1325
+ chalk.dim(
1326
+ `Linked ${config.name} (${config.app_id}) \u2192 ${APP_CONFIG_FILENAME}`
1327
+ )
1328
+ );
1329
+ return config;
1330
+ }
1331
+ async function withRoute(c2, app) {
1332
+ if (app.route) return { app_id: app.id, name: app.name, route: app.route };
1333
+ if (c2.agent) {
1334
+ throw new DeployAbort({
1335
+ code: "ROUTE_REQUIRED",
1336
+ message: `App ${app.id} (${app.name}) has no route \u2014 hosted apps are served from <route>.whop.app. Ask the user which route to claim, then run:
1337
+ whop apps update ${app.id} --route <route>
1338
+ whop apps deploy --app ${app.id}`,
1339
+ retryable: true,
1340
+ cta: {
1341
+ commands: [
1342
+ {
1343
+ command: `apps update ${app.id}`,
1344
+ description: "Set the route (pass --route)"
1345
+ }
1346
+ ]
1347
+ }
1348
+ });
1173
1349
  }
1174
- if (!route && !linked?.route) {
1175
- return c2.error({
1350
+ const slug = slugify(app.name);
1351
+ const routeInput = await promptText(
1352
+ `${app.name} has no route yet. Which route should it live at? (your-route.whop.app)`,
1353
+ slug,
1354
+ slug
1355
+ );
1356
+ if (routeInput === null) cancelled();
1357
+ const route = slugify(routeInput);
1358
+ if (!route) {
1359
+ throw new DeployAbort({
1176
1360
  code: "ROUTE_REQUIRED",
1177
- message: `Couldn't derive a route from "${name}" \u2014 pass --route with letters and numbers (e.g. --route my-site).`,
1361
+ message: `Couldn't derive a route from "${routeInput}" \u2014 use letters and numbers (e.g. my-site).`,
1178
1362
  retryable: true
1179
1363
  });
1180
1364
  }
1181
- let whopApp;
1182
1365
  try {
1183
- if (linked) {
1184
- whopApp = linked;
1185
- route = options.route ?? linked.route ?? slugify(name);
1186
- if (!linked.route || options.route && options.route !== linked.route) {
1187
- whopApp = await updateAppRoute(linked.id, route);
1188
- }
1189
- } else {
1190
- whopApp = await createApp({
1191
- name,
1192
- route,
1193
- ...options.companyId ? { company_id: options.companyId } : {}
1194
- });
1195
- }
1366
+ const updated = await updateAppRoute(app.id, route);
1367
+ return {
1368
+ app_id: updated.id,
1369
+ name: updated.name,
1370
+ route: updated.route ?? route
1371
+ };
1196
1372
  } catch (err) {
1197
- return c2.error({
1198
- code: "CREATE_FAILED",
1199
- message: err instanceof Error ? err.message : "Failed to register the app",
1373
+ throw new DeployAbort({
1374
+ code: "UPDATE_FAILED",
1375
+ message: err instanceof Error ? err.message : "Failed to set the route",
1200
1376
  retryable: true
1201
1377
  });
1202
1378
  }
1203
- writeAppConfig(projectDir, { app_id: whopApp.id, name, route });
1204
- const { manualSteps } = whopifyProject({ targetDir: projectDir, route });
1205
- if (manualSteps.length > 0) {
1206
- return c2.error({
1207
- code: "WHOPIFY_INCOMPLETE",
1208
- message: `${name} is registered on Whop (${whopApp.id}) and ${APP_CONFIG_FILENAME} is written, but the project isn't fully wired for hosting yet. Complete these steps:
1209
-
1210
- ${formatManualSteps(manualSteps)}
1211
-
1212
- Then run \`whop apps deploy\`. Re-running \`whop apps create --existing\` re-checks the wiring and applies anything it can.`
1213
- });
1214
- }
1215
- const url = whopApp.hosted_url ?? void 0;
1216
- if (!c2.agent && !c2.formatExplicit) {
1217
- console.log(
1218
- [
1219
- "",
1220
- chalk.green.bold(`\u2713 ${linked ? "Linked" : "Created"} ${name}`),
1221
- ` ${chalk.dim(whopApp.id)}${url ? ` \u2192 ${chalk.cyan.underline(url)}` : ""}`,
1222
- "",
1223
- `${chalk.bold("Next")} ${chalk.cyan.bold("whop apps deploy")}`,
1224
- ""
1225
- ].join("\n")
1226
- );
1227
- }
1228
- return c2.ok({ id: whopApp.id, name, route, url, directory: projectDir });
1229
1379
  }
1230
- function buildAppGroup() {
1231
- const app = Cli_exports.create("apps", {
1232
- description: "Build and deploy fully-hosted web apps on Whop (*.whop.app)"
1380
+ function resolveSecretsAppId(appOption) {
1381
+ if (appOption) return appOption;
1382
+ const projectDir = findProjectDir();
1383
+ const config = projectDir ? readAppConfig(projectDir) : null;
1384
+ if (config) return config.app_id;
1385
+ throw new Error(
1386
+ `No app specified. Pass --app app_xxx or run inside a project with ${APP_CONFIG_FILENAME}.`
1387
+ );
1388
+ }
1389
+ function buildSecretsGroup() {
1390
+ const secrets = Cli_exports.create("secrets", {
1391
+ description: "Manage app secrets \u2014 encrypted at rest, injected as env bindings into the hosted runtime and `whop apps dev`"
1233
1392
  });
1234
- app.command("create", {
1235
- description: "Create a Whop app: scaffold a new project (default) or wire up an existing one",
1236
- hint: "Registers the app on Whop, writes whop.app.json, and wires the whop() deploy plugin into the vite config. By default it scaffolds the latest TanStack Start template into ./<route>. Pass --existing to register + whopify the Vite project in the current directory (or --dir) instead \u2014 safe to re-run, already-linked projects just get their wiring re-checked. --link reuses an existing app record in either mode.",
1237
- args: external_exports.object({
1238
- name: external_exports.string().optional().describe("App name (e.g. 'My Site'; when linking, defaults to the linked app's name)")
1393
+ secrets.command("list", {
1394
+ description: "Show the app's secrets",
1395
+ options: external_exports.object({
1396
+ app: external_exports.string().optional().describe("App id (defaults to the project's linked app)")
1239
1397
  }),
1398
+ run: async (c2) => {
1399
+ const remote = await getApp(resolveSecretsAppId(c2.options.app));
1400
+ return c2.ok({ app_id: remote.id, secrets: remote.secrets ?? {} });
1401
+ }
1402
+ });
1403
+ secrets.command("set", {
1404
+ description: "Add or overwrite secrets",
1240
1405
  options: external_exports.object({
1241
- route: external_exports.string().optional().describe("Subdomain route to claim (e.g. my-site \u2192 my-site.whop.app)"),
1242
- dir: external_exports.string().optional().describe(
1243
- "Directory to scaffold into (defaults to ./<route>); with --existing, the project directory (defaults to the current directory)"
1244
- ),
1245
- existing: external_exports.boolean().optional().describe(
1246
- "Wire up the existing Vite project here instead of scaffolding a new one"
1247
- ),
1248
- link: external_exports.string().optional().describe(
1249
- "Reuse an existing app by id (app_xxx) instead of creating a new one \u2014 works when scaffolding and with --existing"
1250
- ),
1251
- company_id: external_exports.string().optional().describe(
1252
- "Business account to create the app under (defaults to your active account)"
1253
- ),
1254
- install: external_exports.boolean().optional().describe(
1255
- "Install dependencies after scaffolding fresh (default: true)"
1256
- ),
1257
- pm: external_exports.enum(["npm", "pnpm", "bun", "yarn"]).optional().describe(
1258
- "Package manager to use (default: first of bun, pnpm, npm found on your PATH)"
1259
- )
1406
+ secret: external_exports.array(external_exports.string()).min(1).describe("KEY=VALUE pair (repeatable)"),
1407
+ app: external_exports.string().optional().describe("App id (defaults to the project's linked app)")
1260
1408
  }),
1261
- output: AppSchema,
1262
- outputPolicy: "agent-only",
1263
1409
  examples: [
1264
- {
1265
- args: { name: "My Site" },
1266
- options: { route: "my-site" },
1267
- description: "Scaffold 'My Site' into ./my-site, live at my-site.whop.app"
1268
- },
1269
- {
1270
- args: { name: "My Site" },
1271
- options: { existing: true },
1272
- description: "Register + wire up the Vite project in the current directory"
1273
- }
1410
+ { options: { secret: ["MAIL_API_KEY=mail-key-123"] } },
1411
+ { options: { secret: ["A=1", "B=2"], app: "app_xxxxxxxx" } }
1274
1412
  ],
1275
1413
  run: async (c2) => {
1276
- const explicitDir = c2.options.dir ? resolve2(c2.options.dir) : void 0;
1277
- if (c2.options.existing) {
1278
- const linkedDir = explicitDir ?? findProjectDir();
1279
- const projectDir = linkedDir && readAppConfig(linkedDir) ? linkedDir : explicitDir ?? process.cwd();
1280
- return runRegisterFlow(c2, {
1281
- dir: projectDir,
1282
- name: c2.args.name,
1283
- route: c2.options.route,
1284
- link: c2.options.link,
1285
- companyId: c2.options.company_id
1286
- });
1287
- }
1288
- if (!explicitDir && existsSync3(join3(process.cwd(), "package.json"))) {
1289
- log(
1290
- c2,
1291
- chalk.dim(
1292
- "Detected an existing project here \u2014 scaffolding a new app in a subdirectory. Pass --existing to wire up this project instead."
1293
- )
1294
- );
1295
- }
1296
- let linked = null;
1297
- if (c2.options.link) {
1298
- const apps = await listApps();
1299
- linked = apps.find((a) => a.id === c2.options.link) ?? null;
1300
- if (!linked) {
1301
- return c2.error({
1302
- code: "APP_NOT_FOUND",
1303
- message: `No app ${c2.options.link} found on your account.`
1304
- });
1305
- }
1306
- }
1307
- let name = c2.args.name ?? linked?.name;
1308
- if (!name) {
1309
- if (c2.agent) {
1414
+ const updates = {};
1415
+ for (const pair of c2.options.secret) {
1416
+ const eq = pair.indexOf("=");
1417
+ if (eq <= 0) {
1310
1418
  return c2.error({
1311
- code: "NAME_REQUIRED",
1312
- message: 'Pass a name: `whop apps create "My Site"`.',
1313
- retryable: true
1314
- });
1315
- }
1316
- const value = await promptText("What is your app called?", "My Site");
1317
- if (value === null)
1318
- return c2.error({
1319
- code: "CANCELLED",
1320
- message: "Cancelled.",
1321
- exitCode: 130
1322
- });
1323
- name = value;
1324
- }
1325
- let route = c2.options.route ?? linked?.route ?? slugify(name);
1326
- if (!c2.options.route && !linked?.route && !c2.agent) {
1327
- const value = await promptText(
1328
- "Which route should it live at? (your-route.whop.app)",
1329
- slugify(name),
1330
- slugify(name)
1331
- );
1332
- if (value === null)
1333
- return c2.error({
1334
- code: "CANCELLED",
1335
- message: "Cancelled.",
1336
- exitCode: 130
1337
- });
1338
- route = slugify(value);
1339
- }
1340
- if (!route) {
1341
- return c2.error({
1342
- code: "ROUTE_REQUIRED",
1343
- message: `Couldn't derive a route from "${name}" \u2014 pass --route with letters and numbers (e.g. --route my-site).`,
1344
- retryable: true
1345
- });
1346
- }
1347
- let pm = c2.options.pm;
1348
- if (pm && !isPmInstalled(pm)) {
1349
- return c2.error({
1350
- code: "PM_NOT_INSTALLED",
1351
- message: `${pm} is not installed (or not on your PATH). Install it: ${pmInstallHint(pm)}`
1352
- });
1353
- }
1354
- if (!pm) pm = detectPackageManager() ?? void 0;
1355
- if (!pm) {
1356
- if (c2.agent) {
1357
- return c2.error({
1358
- code: "NO_PACKAGE_MANAGER",
1359
- message: `No package manager found (looked for bun, pnpm, npm). Install bun (recommended): ${pmInstallHint("bun")} \u2014 then re-run \`whop apps create\`.`
1360
- });
1361
- }
1362
- const choice = await select({
1363
- message: "No package manager found (bun, pnpm, or npm). Which one do you want to install?",
1364
- options: [
1365
- { value: "bun", label: "bun", hint: "recommended" },
1366
- { value: "pnpm", label: "pnpm" },
1367
- { value: "npm", label: "npm", hint: "comes with Node.js" }
1368
- ]
1369
- });
1370
- if (isCancel(choice)) {
1371
- return c2.error({
1372
- code: "CANCELLED",
1373
- message: "Cancelled.",
1374
- exitCode: 130
1375
- });
1376
- }
1377
- return c2.error({
1378
- code: "NO_PACKAGE_MANAGER",
1379
- message: `Install ${String(choice)} with:
1380
-
1381
- ${pmInstallHint(choice)}
1382
-
1383
- then re-run \`whop apps create\`.`
1384
- });
1385
- }
1386
- const targetDir = resolve2(c2.options.dir ?? `./${route}`);
1387
- if (existsSync3(targetDir) && readdirSync(targetDir).length > 0) {
1388
- return c2.error({
1389
- code: "TARGET_NOT_EMPTY",
1390
- message: `Target directory is not empty: ${targetDir}. Choose an empty directory with --dir, or a different route.`
1391
- });
1392
- }
1393
- let whopApp;
1394
- try {
1395
- if (linked) {
1396
- whopApp = linked;
1397
- if (!linked.route || c2.options.route && c2.options.route !== linked.route) {
1398
- whopApp = await updateAppRoute(linked.id, route);
1399
- }
1400
- } else {
1401
- whopApp = await createApp({
1402
- name,
1403
- route,
1404
- ...c2.options.company_id ? { company_id: c2.options.company_id } : {}
1419
+ code: "INVALID_SECRET",
1420
+ message: `"${pair}" is not KEY=VALUE. Use --secret NAME=value (an empty value deletes the secret).`
1405
1421
  });
1406
1422
  }
1407
- } catch (err) {
1408
- return c2.error({
1409
- code: "CREATE_FAILED",
1410
- message: err instanceof Error ? err.message : "Failed to create the app on Whop",
1411
- retryable: true
1412
- });
1413
- }
1414
- let whopifySteps = [];
1415
- try {
1416
- log(
1417
- c2,
1418
- chalk.dim("\nScaffolding with the latest TanStack Start template...")
1419
- );
1420
- runTanstackCreate({
1421
- targetDir,
1422
- projectName: route,
1423
- pm,
1424
- quiet: Boolean(c2.agent)
1425
- });
1426
- whopifySteps = whopifyProject({ targetDir, route }).manualSteps;
1427
- } catch (err) {
1428
- return c2.error({
1429
- code: "SCAFFOLD_FAILED",
1430
- message: err instanceof Error ? err.message : "Failed to scaffold the project"
1431
- });
1432
- }
1433
- writeAppConfig(targetDir, { app_id: whopApp.id, name, route });
1434
- if (whopifySteps.length > 0) {
1435
- return c2.error({
1436
- code: "WHOPIFY_INCOMPLETE",
1437
- message: `Scaffolded ${targetDir} and registered ${whopApp.id}, but the project needs manual wiring:
1438
-
1439
- ${formatManualSteps(whopifySteps)}
1440
-
1441
- Then run \`whop apps deploy\`. Re-running \`whop apps create --existing\` in the project re-checks the wiring.`
1442
- });
1423
+ updates[pair.slice(0, eq)] = pair.slice(eq + 1);
1443
1424
  }
1444
- let installFailed = false;
1445
- if (c2.options.install !== false) {
1446
- log(c2, chalk.dim(`
1447
- Installing dependencies with ${pm}...`));
1448
- const result = spawnSync2(pm, ["install"], {
1449
- cwd: targetDir,
1450
- stdio: c2.agent ? "ignore" : "inherit",
1451
- shell: SPAWN_VIA_SHELL2
1452
- });
1453
- installFailed = Boolean(result.error) || result.status !== 0;
1454
- if (installFailed) {
1455
- log(
1456
- c2,
1457
- chalk.yellow(
1458
- `
1459
- ${pm} install failed \u2014 run it manually inside ${targetDir}.`
1460
- )
1461
- );
1462
- }
1463
- }
1464
- const url = whopApp.hosted_url ?? void 0;
1465
- if (!c2.agent && !c2.formatExplicit) {
1466
- console.log(
1467
- [
1468
- "",
1469
- chalk.green.bold(`\u2713 ${name} is ready`),
1470
- ` ${chalk.dim(whopApp.id)}`,
1471
- "",
1472
- ` ${chalk.bold("Directory")} ${targetDir}`,
1473
- ...url ? [
1474
- ` ${chalk.bold("URL")} ${chalk.cyan.underline(url)} ${chalk.dim("(after first deploy)")}`
1475
- ] : [],
1476
- "",
1477
- `${chalk.bold("Next")}`,
1478
- ` cd ${targetDir}`,
1479
- ` ${chalk.cyan.bold("whop apps dev")} ${chalk.dim("\u2192 local dev server")}`,
1480
- ` ${chalk.cyan.bold("whop apps deploy")} ${chalk.dim("\u2192 ship it live")}`,
1481
- ""
1482
- ].join("\n")
1483
- );
1484
- }
1485
- return c2.ok({
1486
- id: whopApp.id,
1487
- name,
1488
- route,
1489
- url,
1490
- directory: targetDir,
1491
- install_failed: installFailed
1492
- });
1425
+ const remote = await updateAppSecrets(
1426
+ resolveSecretsAppId(c2.options.app),
1427
+ updates
1428
+ );
1429
+ return c2.ok({ app_id: remote.id, secrets: remote.secrets ?? {} });
1493
1430
  }
1494
1431
  });
1495
- app.command("list", {
1496
- description: "List the apps on your business account",
1497
- output: external_exports.array(AppSchema),
1498
- outputPolicy: "agent-only",
1432
+ secrets.command("unset", {
1433
+ description: "Delete secrets by name",
1434
+ options: external_exports.object({
1435
+ key: external_exports.array(external_exports.string()).min(1).describe("Secret name (repeatable)"),
1436
+ app: external_exports.string().optional().describe("App id (defaults to the project's linked app)")
1437
+ }),
1438
+ examples: [{ options: { key: ["MAIL_API_KEY"] } }],
1499
1439
  run: async (c2) => {
1500
- const apps = await listApps();
1501
- return c2.ok(
1502
- apps.map((a) => ({
1503
- id: a.id,
1504
- name: a.name,
1505
- route: a.route,
1506
- url: a.hosted_url ?? void 0
1507
- }))
1440
+ const updates = {};
1441
+ for (const key of c2.options.key) updates[key] = null;
1442
+ const remote = await updateAppSecrets(
1443
+ resolveSecretsAppId(c2.options.app),
1444
+ updates
1508
1445
  );
1446
+ return c2.ok({ app_id: remote.id, secrets: remote.secrets ?? {} });
1509
1447
  }
1510
1448
  });
1449
+ return secrets;
1450
+ }
1451
+ async function buildAppGroup() {
1452
+ const app = Cli_exports.create("apps", {
1453
+ description: "Build and deploy fully-hosted web apps on Whop (*.whop.app)"
1454
+ });
1455
+ await registerSpecCommands(app, "Apps");
1456
+ const builds = Cli_exports.create("builds", {
1457
+ description: "Manage app builds (list, inspect, promote)"
1458
+ });
1459
+ await registerSpecCommands(builds, "App builds");
1460
+ app.command(builds);
1461
+ app.command(buildSecretsGroup());
1511
1462
  app.command("dev", {
1512
1463
  description: "Run the local dev server for this app",
1513
1464
  hint: "Starts the project's dev script with WHOP_APP_ID set and a short-lived access token injected as WHOP_API_KEY (minted from your CLI credential), so server-side SDK calls work locally without env setup. An explicitly exported WHOP_API_KEY is used as-is.",
@@ -1515,10 +1466,24 @@ ${pm} install failed \u2014 run it manually inside ${targetDir}.`
1515
1466
  dir: external_exports.string().optional().describe("Project directory (defaults to the current directory)")
1516
1467
  }),
1517
1468
  run: async (c2) => {
1518
- const project = loadProject(c2.options.dir);
1519
- if (!project.ok) return c2.error(project.error);
1469
+ const projectDir = c2.options.dir ? resolve2(c2.options.dir) : findProjectDir();
1470
+ const config = projectDir ? readAppConfig(projectDir) : null;
1471
+ if (!projectDir || !config) {
1472
+ return c2.error({
1473
+ code: "NO_PROJECT",
1474
+ message: `No ${APP_CONFIG_FILENAME} found in this directory or any parent. Run \`whop apps deploy\` to link this project to a Whop app.`,
1475
+ cta: {
1476
+ commands: [
1477
+ {
1478
+ command: "apps deploy",
1479
+ description: "Link this project to a Whop app and ship it"
1480
+ }
1481
+ ]
1482
+ }
1483
+ });
1484
+ }
1520
1485
  const injected = {
1521
- WHOP_APP_ID: project.config.app_id
1486
+ WHOP_APP_ID: config.app_id
1522
1487
  };
1523
1488
  if (process.env.WHOP_API_KEY) {
1524
1489
  log(
@@ -1548,9 +1513,43 @@ ${pm} install failed \u2014 run it manually inside ${targetDir}.`
1548
1513
  );
1549
1514
  }
1550
1515
  }
1551
- log(c2, chalk.dim(`Starting dev server for ${project.config.name}...`));
1552
1516
  try {
1553
- runScript(project.projectDir, "dev", injected);
1517
+ const remote = await getApp(config.app_id);
1518
+ const secretEntries = Object.entries(remote.secrets ?? {}).filter(
1519
+ ([key]) => !(key in process.env) && !(key in injected)
1520
+ );
1521
+ const unsafe = secretEntries.filter(([key]) => isUnsafeDevEnvKey(key));
1522
+ for (const [key, value] of secretEntries) {
1523
+ if (!isUnsafeDevEnvKey(key)) injected[key] = value;
1524
+ }
1525
+ if (unsafe.length > 0) {
1526
+ log(
1527
+ c2,
1528
+ chalk.yellow(
1529
+ `Skipped app secret${unsafe.length === 1 ? "" : "s"} ${unsafe.map(([key]) => key).join(", ")} \u2014 these names control the local runtime and are never injected by \`whop apps dev\`.`
1530
+ )
1531
+ );
1532
+ }
1533
+ const injectedCount = secretEntries.length - unsafe.length;
1534
+ if (injectedCount > 0) {
1535
+ log(
1536
+ c2,
1537
+ chalk.dim(
1538
+ `Injected ${injectedCount} app secret${injectedCount === 1 ? "" : "s"} into the environment (${secretEntries.filter(([key]) => !isUnsafeDevEnvKey(key)).map(([key]) => key).join(", ")}).`
1539
+ )
1540
+ );
1541
+ }
1542
+ } catch (err) {
1543
+ log(
1544
+ c2,
1545
+ chalk.yellow(
1546
+ `Couldn't fetch app secrets (${err instanceof Error ? err.message : "unknown error"}) \u2014 continuing without them.`
1547
+ )
1548
+ );
1549
+ }
1550
+ log(c2, chalk.dim(`Starting dev server for ${config.name}...`));
1551
+ try {
1552
+ runScript(projectDir, "dev", injected);
1554
1553
  } catch (err) {
1555
1554
  return c2.error({
1556
1555
  code: "DEV_FAILED",
@@ -1562,12 +1561,13 @@ ${pm} install failed \u2014 run it manually inside ${targetDir}.`
1562
1561
  });
1563
1562
  app.command("deploy", {
1564
1563
  description: "Build, upload and ship this app live",
1565
- hint: "Typechecks, builds with Vite, uploads the build to Whop, and promotes it to production. Use --no-promote to upload a preview-only build.",
1564
+ hint: "The one-stop deploy: verifies the project is a Whop-ready Vite app (offering to scaffold or link one if not), builds, typechecks, uploads the build, and promotes it to production. Use --skip_promote to upload a preview-only build, then promote it later with `whop apps builds promote <build_id>`.",
1566
1565
  options: external_exports.object({
1567
1566
  dir: external_exports.string().optional().describe("Project directory (defaults to the current directory)"),
1568
- promote: external_exports.boolean().optional().describe(
1569
- "Promote this build to production after upload (default: true)"
1567
+ app: external_exports.string().optional().describe(
1568
+ "Link this project to an app (app_xxx) before deploying \u2014 writes whop.app.json, replacing any existing link"
1570
1569
  ),
1570
+ skip_promote: external_exports.boolean().optional().describe("Upload the build without promoting it to production"),
1571
1571
  skip_typecheck: external_exports.boolean().optional().describe("Skip the typecheck step"),
1572
1572
  skip_build: external_exports.boolean().optional().describe("Skip the build step and upload the existing dist/ output")
1573
1573
  }),
@@ -1576,20 +1576,45 @@ ${pm} install failed \u2014 run it manually inside ${targetDir}.`
1576
1576
  examples: [
1577
1577
  { description: "Build and ship to production" },
1578
1578
  {
1579
- options: { promote: false },
1579
+ options: { app: "app_xxxxxxxx" },
1580
+ description: "Link this project to an existing app, then deploy"
1581
+ },
1582
+ {
1583
+ options: { skip_promote: true },
1580
1584
  description: "Upload a preview build without promoting"
1581
1585
  }
1582
1586
  ],
1583
1587
  run: async (c2) => {
1584
- const project = loadProject(c2.options.dir);
1585
- if (!project.ok) return c2.error(project.error);
1586
- const { projectDir, config } = project;
1587
1588
  try {
1589
+ const pm = ensurePackageManager();
1590
+ const { projectDir, freshlyScaffolded } = await ensureViteApp(
1591
+ c2,
1592
+ pm,
1593
+ c2.options.dir
1594
+ );
1595
+ const config = await ensureAppLinked(c2, projectDir, c2.options.app);
1596
+ const { manualSteps } = whopifyProject({
1597
+ targetDir: projectDir,
1598
+ route: config.route
1599
+ });
1600
+ if (manualSteps.length > 0) {
1601
+ return c2.error({
1602
+ code: "WHOPIFY_INCOMPLETE",
1603
+ message: `${config.name} is linked (${config.app_id}), but the project isn't fully wired for Whop hosting yet. Complete these steps:
1604
+
1605
+ ${formatManualSteps(manualSteps)}
1606
+
1607
+ Then re-run \`whop apps deploy\` \u2014 it re-checks the wiring and applies anything it can.`
1608
+ });
1609
+ }
1588
1610
  if (!c2.options.skip_build) {
1589
- log(c2, chalk.dim("[1/3] Building..."));
1611
+ if (freshlyScaffolded || !existsSync3(join3(projectDir, "node_modules", "@whop", "cli"))) {
1612
+ runInstall(c2, projectDir);
1613
+ }
1614
+ log(c2, chalk.dim("[1/4] Building..."));
1590
1615
  runScript(projectDir, "build", { WHOP_APP_ID: config.app_id });
1591
1616
  if (!c2.options.skip_typecheck && hasScript(projectDir, "typecheck")) {
1592
- log(c2, chalk.dim("[2/3] Typechecking..."));
1617
+ log(c2, chalk.dim("[2/4] Typechecking..."));
1593
1618
  runScript(projectDir, "typecheck");
1594
1619
  }
1595
1620
  }
@@ -1600,26 +1625,50 @@ ${pm} install failed \u2014 run it manually inside ${targetDir}.`
1600
1625
  message: `No build archive at ${BUILD_ARCHIVE}. Add the whop() plugin from @whop/cli/vite to your vite config, then build. See https://whop.com/docs/apps/hosting.`
1601
1626
  });
1602
1627
  }
1603
- log(c2, chalk.dim("[3/3] Uploading + creating build..."));
1604
1628
  const zip = readFileSync3(archivePath);
1629
+ const sizeMb = (zip.byteLength / 1024 / 1024).toFixed(1);
1605
1630
  const checksum = createHash("sha256").update(zip).digest("hex");
1606
- const fileId = await uploadBuildArchive(
1607
- new Uint8Array(zip),
1608
- `${config.route}-build.zip`
1609
- );
1610
- await waitForFileReady(fileId);
1611
- let build = await createAppBuild({
1612
- app_id: config.app_id,
1613
- checksum,
1614
- file_id: fileId
1615
- });
1616
- if (c2.options.promote !== false) {
1617
- await promoteAppBuild(build.id);
1618
- build = await waitForBuildPromotion(build.id);
1631
+ const spin = c2.agent ? null : spinner();
1632
+ spin?.start(`[3/4] Uploading build archive (${sizeMb} MB)`);
1633
+ let build;
1634
+ try {
1635
+ const fileId = await uploadBuildArchive(
1636
+ new Uint8Array(zip),
1637
+ `${config.route}-build.zip`
1638
+ );
1639
+ spin?.message("[3/4] Processing archive");
1640
+ await waitForFileReady(fileId);
1641
+ spin?.message("[3/4] Creating build");
1642
+ build = await createAppBuild({
1643
+ app_id: config.app_id,
1644
+ checksum,
1645
+ file_id: fileId
1646
+ });
1647
+ spin?.stop(`[3/4] Build uploaded (${build.id})`);
1648
+ } catch (err) {
1649
+ spin?.error("[3/4] Upload failed");
1650
+ throw err;
1651
+ }
1652
+ if (!c2.options.skip_promote) {
1653
+ const promoteSpin = c2.agent ? null : spinner();
1654
+ promoteSpin?.start("[4/4] Promoting to production");
1655
+ try {
1656
+ await promoteAppBuild(build.id);
1657
+ build = await waitForBuildPromotion(build.id);
1658
+ promoteSpin?.stop(
1659
+ build.is_production ? "[4/4] Promoted to production" : `[4/4] Promotion pending (status: ${build.status})`
1660
+ );
1661
+ } catch (err) {
1662
+ promoteSpin?.error("[4/4] Promote failed");
1663
+ throw err;
1664
+ }
1665
+ } else {
1666
+ log(c2, chalk.dim("[4/4] Skipping promotion (--skip_promote)"));
1619
1667
  }
1620
1668
  const promoted = build.is_production === true;
1621
1669
  const productionUrl = promoted ? (await getApp(config.app_id)).hosted_url ?? void 0 : void 0;
1622
1670
  if (!c2.agent && !c2.formatExplicit) {
1671
+ const cdTarget = relative(process.cwd(), projectDir) || ".";
1623
1672
  console.log(
1624
1673
  [
1625
1674
  "",
@@ -1631,19 +1680,39 @@ ${pm} install failed \u2014 run it manually inside ${targetDir}.`
1631
1680
  ...promoted && productionUrl ? [
1632
1681
  ` ${chalk.bold("Live")} ${chalk.cyan.underline(productionUrl)}`
1633
1682
  ] : [
1634
- `${chalk.bold("Promote when ready")} ${chalk.cyan.bold(`whop apps promote ${build.id}`)}`
1683
+ `${chalk.bold("Promote when ready")} ${chalk.cyan.bold(`whop apps builds promote ${build.id}`)}`
1635
1684
  ],
1685
+ ...freshlyScaffolded ? [
1686
+ "",
1687
+ chalk.bold("Next"),
1688
+ ...cdTarget !== "." ? [` cd ${cdTarget}`] : [],
1689
+ ` ${chalk.cyan.bold("whop apps dev")} ${chalk.dim("\u2192 local dev server")}`,
1690
+ ` ${chalk.cyan.bold("whop apps deploy")} ${chalk.dim("\u2192 ship your changes")}`
1691
+ ] : [],
1636
1692
  ""
1637
1693
  ].join("\n")
1638
1694
  );
1639
1695
  }
1640
- return c2.ok({
1641
- id: build.id,
1642
- status: build.status,
1643
- is_production: promoted,
1644
- url: productionUrl
1645
- });
1696
+ return c2.ok(
1697
+ {
1698
+ id: build.id,
1699
+ status: build.status,
1700
+ is_production: promoted,
1701
+ url: productionUrl
1702
+ },
1703
+ promoted ? void 0 : {
1704
+ cta: {
1705
+ commands: [
1706
+ {
1707
+ command: `apps builds promote ${build.id}`,
1708
+ description: "Promote this build to production"
1709
+ }
1710
+ ]
1711
+ }
1712
+ }
1713
+ );
1646
1714
  } catch (err) {
1715
+ if (err instanceof DeployAbort) return c2.error(err.options);
1647
1716
  return c2.error({
1648
1717
  code: "DEPLOY_FAILED",
1649
1718
  message: err instanceof Error ? err.message : "Deploy failed",
@@ -1652,91 +1721,6 @@ ${pm} install failed \u2014 run it manually inside ${targetDir}.`
1652
1721
  }
1653
1722
  }
1654
1723
  });
1655
- app.command("promote", {
1656
- description: "Promote a previously uploaded build to production",
1657
- args: external_exports.object({
1658
- build_id: external_exports.string().describe("The build to promote (apbu_xxx)")
1659
- }),
1660
- options: external_exports.object({
1661
- dir: external_exports.string().optional().describe("Project directory (defaults to the current directory)")
1662
- }),
1663
- output: BuildSchema,
1664
- outputPolicy: "agent-only",
1665
- run: async (c2) => {
1666
- const project = loadProject(c2.options.dir);
1667
- if (!project.ok) return c2.error(project.error);
1668
- try {
1669
- await promoteAppBuild(c2.args.build_id);
1670
- const build = await waitForBuildPromotion(c2.args.build_id);
1671
- if (!build.is_production) {
1672
- return c2.error({
1673
- code: "PROMOTE_PENDING",
1674
- message: `Build ${build.id} was accepted but is still processing (status: ${build.status}). It will go live once approved \u2014 re-run \`whop apps promote ${build.id}\` to confirm.`,
1675
- retryable: true
1676
- });
1677
- }
1678
- const url = (await getApp(project.config.app_id)).hosted_url ?? void 0;
1679
- if (!c2.agent && !c2.formatExplicit) {
1680
- console.log(
1681
- [
1682
- "",
1683
- chalk.green.bold("\u2713 Promoted to production"),
1684
- ` ${chalk.dim(build.id)}`,
1685
- ...url ? [` ${chalk.bold("Live")} ${chalk.cyan.underline(url)}`] : [],
1686
- ""
1687
- ].join("\n")
1688
- );
1689
- }
1690
- return c2.ok({
1691
- id: build.id,
1692
- status: build.status,
1693
- is_production: build.is_production,
1694
- url
1695
- });
1696
- } catch (err) {
1697
- return c2.error({
1698
- code: "PROMOTE_FAILED",
1699
- message: err instanceof Error ? err.message : "Promote failed",
1700
- retryable: true
1701
- });
1702
- }
1703
- }
1704
- });
1705
- app.command("builds", {
1706
- description: "List builds for this app",
1707
- options: external_exports.object({
1708
- dir: external_exports.string().optional().describe("Project directory (defaults to the current directory)")
1709
- }),
1710
- output: external_exports.object({ builds: external_exports.array(BuildSchema) }),
1711
- outputPolicy: "agent-only",
1712
- run: async (c2) => {
1713
- const project = loadProject(c2.options.dir);
1714
- if (!project.ok) return c2.error(project.error);
1715
- try {
1716
- const builds = await listAppBuilds(project.config.app_id);
1717
- const rows = builds.map((build) => ({
1718
- id: build.id,
1719
- status: build.status,
1720
- is_production: build.is_production
1721
- }));
1722
- if (!c2.agent && !c2.formatExplicit) {
1723
- console.log("");
1724
- for (const row of rows) {
1725
- const marker = row.is_production ? chalk.green("\u25CF production") : chalk.dim(`\u25CB ${row.status}`);
1726
- console.log(` ${row.id} ${marker}`);
1727
- }
1728
- console.log("");
1729
- }
1730
- return c2.ok({ builds: rows });
1731
- } catch (err) {
1732
- return c2.error({
1733
- code: "LIST_FAILED",
1734
- message: err instanceof Error ? err.message : "Failed to list builds",
1735
- retryable: true
1736
- });
1737
- }
1738
- }
1739
- });
1740
1724
  return app;
1741
1725
  }
1742
1726
 
@@ -2356,7 +2340,7 @@ ${c.success("\u2713 Your store is fully set up.")}`);
2356
2340
  // package.json
2357
2341
  var package_default = {
2358
2342
  name: "@whop/cli",
2359
- version: "0.3.0",
2343
+ version: "0.5.0",
2360
2344
  description: "The Whop CLI \u2014 build and manage Whop apps from your terminal. Human and agent friendly.",
2361
2345
  keywords: [
2362
2346
  "agent",
@@ -2504,7 +2488,7 @@ var DEFAULT_MANIFEST_URL = "https://github.com/whopio/whop-public-cli/releases/l
2504
2488
  function manifestUrl(env = process.env) {
2505
2489
  return env.WHOP_CLI_MANIFEST_URL?.trim() || DEFAULT_MANIFEST_URL;
2506
2490
  }
2507
- var CHECK_INTERVAL_MS = 24 * 60 * 60 * 1e3;
2491
+ var CHECK_INTERVAL_MS = 60 * 60 * 1e3;
2508
2492
  function cachePath(env) {
2509
2493
  return path.join(configDir(env), "update-check.json");
2510
2494
  }
@@ -2874,7 +2858,7 @@ var HANDWRITTEN_GROUPS = [
2874
2858
  name: "apps",
2875
2859
  description: "Build and deploy fully-hosted web apps (*.whop.app)",
2876
2860
  section: "get-started",
2877
- register: (cli2) => cli2.command(buildAppGroup())
2861
+ register: async (cli2) => cli2.command(await buildAppGroup())
2878
2862
  },
2879
2863
  {
2880
2864
  name: "upgrade",
@@ -2931,8 +2915,8 @@ var HANDWRITTEN_GROUP_NAMES = new Set(
2931
2915
  );
2932
2916
  var API_GROUPS = groups_default.map(([name, tag]) => ({ name, tag })).filter(({ name }) => !HANDWRITTEN_GROUP_NAMES.has(name));
2933
2917
  var COMMAND_GROUPS = [...API_GROUPS, ...HANDWRITTEN_GROUPS];
2934
- function registerHandwrittenGroups(cli2) {
2935
- for (const group of HANDWRITTEN_GROUPS) group.register(cli2);
2918
+ async function registerHandwrittenGroups(cli2) {
2919
+ for (const group of HANDWRITTEN_GROUPS) await group.register(cli2);
2936
2920
  }
2937
2921
  async function setupAgents(cli2) {
2938
2922
  for (const argv2 of [
@@ -3073,7 +3057,7 @@ cli.use(async (c2, next) => {
3073
3057
  }
3074
3058
  return next();
3075
3059
  });
3076
- registerHandwrittenGroups(cli);
3060
+ await registerHandwrittenGroups(cli);
3077
3061
  for (const { name, tag } of API_GROUPS) {
3078
3062
  cli.command(name, { fetch: fetch2, openapi: spec(tag) });
3079
3063
  }
@@ -3128,6 +3112,12 @@ var api_structure_default = [
3128
3112
  "Audiences"
3129
3113
  ]
3130
3114
  },
3115
+ {
3116
+ group: "Media",
3117
+ tags: [
3118
+ "Media"
3119
+ ]
3120
+ },
3131
3121
  {
3132
3122
  group: "Identity",
3133
3123
  tags: [