@ramonclaudio/vexpo 0.1.0 → 0.1.1

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/cli.js CHANGED
@@ -1,28 +1,39 @@
1
1
  #!/usr/bin/env node
2
- import { ascStatus, ascConnect } from './chunk-A43VGOE3.js';
2
+ import { ensureLine, readOne, readAll, removeLines, ENV_FILE } from './chunk-3TT4CDAJ.js';
3
+ import { ascStatus } from './chunk-VOL7YISA.js';
3
4
  import { dlx, detectPackageManager, installCmdFor } from './chunk-PYXH4J77.js';
4
- import { spawn, streamText, run } from './chunk-QFP5R25M.js';
5
- import { unansweredOlderThan, reviews } from './chunk-5JSZTHAP.js';
5
+ import { run, spawn } from './chunk-QFP5R25M.js';
6
6
  import { Command } from 'commander';
7
- import { readFile, unlink, stat, mkdir, access, writeFile, rename } from 'fs/promises';
7
+ import { readFile, access, writeFile, unlink, stat, mkdir, rename } from 'fs/promises';
8
8
  import { createInterface } from 'readline/promises';
9
- import { existsSync } from 'fs';
10
9
  import { homedir } from 'os';
10
+ import { join } from 'path';
11
+ import { existsSync, readFileSync } from 'fs';
11
12
  import { createSign } from 'crypto';
12
13
 
13
14
  // package.json
14
15
  var package_default = {
15
- version: "0.1.0"};
16
+ version: "0.1.1"};
16
17
  function deploymentName(value) {
17
18
  if (!value) return void 0;
18
19
  const m = /^(?:dev|prod|preview):(.+)$/.exec(value);
19
20
  return m ? m[1] : value;
20
21
  }
21
22
  function targetArgs(target) {
22
- if (target?.prod) return ["--prod"];
23
+ if (target?.prod) {
24
+ return target.envFile ? ["--env-file", target.envFile] : ["--prod"];
25
+ }
23
26
  const explicit = target?.deployment ?? deploymentName(process.env.CONVEX_DEPLOYMENT);
24
27
  return explicit ? ["--deployment", explicit] : [];
25
28
  }
29
+ function unquoteEnvValue(value) {
30
+ const q = value[0];
31
+ if ((q === '"' || q === "'") && value.length >= 2 && value[value.length - 1] === q) {
32
+ const inner = value.slice(1, -1);
33
+ return q === '"' ? inner.replace(/\\n/g, "\n") : inner;
34
+ }
35
+ return value;
36
+ }
26
37
  async function envMap(target) {
27
38
  const argv = [dlx(), "convex", "env", "list", ...targetArgs(target)];
28
39
  const { code, stdout } = await run(argv);
@@ -32,7 +43,7 @@ async function envMap(target) {
32
43
  const trimmed = raw.trim();
33
44
  if (!trimmed) continue;
34
45
  const eq = trimmed.indexOf("=");
35
- if (eq > 0) out.set(trimmed.slice(0, eq), trimmed.slice(eq + 1));
46
+ if (eq > 0) out.set(trimmed.slice(0, eq), unquoteEnvValue(trimmed.slice(eq + 1)));
36
47
  }
37
48
  return out;
38
49
  }
@@ -75,6 +86,9 @@ async function isLoggedIn() {
75
86
  return false;
76
87
  }
77
88
  }
89
+ function deploymentSlug(value) {
90
+ return deploymentName(value);
91
+ }
78
92
  async function checkCli() {
79
93
  const { code, stdout } = await run([dlx(), "eas", "--version"]);
80
94
  if (code !== 0) return { ok: false };
@@ -87,19 +101,26 @@ async function whoami() {
87
101
  const text = stdout.trim();
88
102
  return text ? text.split("\n")[0].trim() : null;
89
103
  }
90
- async function projectIdFromAppJson() {
104
+ async function resolveProjectId() {
91
105
  try {
92
- try {
93
- await access("app.json");
94
- } catch {
95
- return null;
96
- }
106
+ await access("app.json");
97
107
  const json = JSON.parse(await readFile("app.json", "utf8"));
98
108
  const value = json.expo?.extra?.eas?.projectId;
99
- return value && value.length > 0 ? value : null;
109
+ if (value && value.length > 0) return value;
110
+ } catch {
111
+ }
112
+ const fromProcess = process.env.EAS_PROJECT_ID;
113
+ if (fromProcess && fromProcess.length > 0) return fromProcess;
114
+ try {
115
+ const { readOne: readOne2 } = await import('./env-local-TW3T2PZ6.js');
116
+ const fromFile = await readOne2("EAS_PROJECT_ID");
117
+ if (fromFile && fromFile.length > 0) {
118
+ process.env.EAS_PROJECT_ID = fromFile;
119
+ return fromFile;
120
+ }
100
121
  } catch {
101
- return null;
102
122
  }
123
+ return null;
103
124
  }
104
125
  async function envList(environment = "production") {
105
126
  const { code, stdout } = await run([
@@ -155,7 +176,7 @@ async function envUpdate(name, value, visibility, environments = ["production",
155
176
  visibility
156
177
  ];
157
178
  if (opts?.type) argv.push("--type", opts.type);
158
- for (const env2 of environments) argv.push("--environment", env2);
179
+ for (const env2 of environments) argv.push("--variable-environment", env2);
159
180
  argv.push("--non-interactive");
160
181
  const { code, stderr } = await run(argv);
161
182
  if (code !== 0) {
@@ -164,18 +185,18 @@ async function envUpdate(name, value, visibility, environments = ["production",
164
185
  }
165
186
  }
166
187
  async function envPush(opts) {
167
- const argv = [dlx(), "eas", "env:push"];
168
- for (const env2 of opts.environments) argv.push("--environment", env2);
169
- argv.push("--path", opts.path);
170
- if (opts.force) argv.push("--force");
171
- const { code, stderr } = await run(argv);
172
- if (code !== 0) {
173
- const tail = stderr.trim().split("\n").pop()?.trim() ?? `exit ${code}`;
174
- throw new Error(`eas env:push failed: ${tail}`);
188
+ for (const env2 of opts.environments) {
189
+ const argv = [dlx(), "eas", "env:push", "--environment", env2, "--path", opts.path];
190
+ if (opts.force) argv.push("--force");
191
+ const { code, stderr } = await run(argv);
192
+ if (code !== 0) {
193
+ const tail = stderr.trim().split("\n").pop()?.trim() ?? `exit ${code}`;
194
+ throw new Error(`eas env:push (${env2}) failed: ${tail}`);
195
+ }
175
196
  }
176
197
  }
177
198
  async function init() {
178
- const existing = await projectIdFromAppJson();
199
+ const existing = await resolveProjectId();
179
200
  const argv = existing ? [dlx(), "eas", "init", "--non-interactive", "--force", "--id", existing] : [dlx(), "eas", "init", "--non-interactive", "--force"];
180
201
  const proc = spawn(argv, {
181
202
  stdin: "inherit",
@@ -183,7 +204,7 @@ async function init() {
183
204
  stderr: "inherit"
184
205
  });
185
206
  if (await proc.exited !== 0) return { ok: false };
186
- const id = await projectIdFromAppJson();
207
+ const id = await resolveProjectId();
187
208
  return { ok: !!id, projectId: id ?? void 0 };
188
209
  }
189
210
  async function diagnostics() {
@@ -323,13 +344,20 @@ async function call(method, path, key, body) {
323
344
  throw new Error(`Resend ${method} ${path} \u2192 429 after retries`);
324
345
  }
325
346
  async function probeAccess(key) {
326
- const res = await fetch(`${BASE}/api-keys`, {
327
- headers: { Authorization: `Bearer ${key}` }
328
- });
329
- if (res.ok) return "full";
330
- const text = await res.text();
331
- if (text.includes("restricted_api_key")) return "sending";
332
- return "invalid";
347
+ const ctl = new AbortController();
348
+ const timer = setTimeout(() => ctl.abort(), REQUEST_TIMEOUT_MS);
349
+ try {
350
+ const res = await fetch(`${BASE}/api-keys`, {
351
+ headers: { Authorization: `Bearer ${key}` },
352
+ signal: ctl.signal
353
+ });
354
+ if (res.ok) return "full";
355
+ const text = await res.text();
356
+ if (text.includes("restricted_api_key")) return "sending";
357
+ return "invalid";
358
+ } finally {
359
+ clearTimeout(timer);
360
+ }
333
361
  }
334
362
  async function listDomains(key) {
335
363
  return (await call("GET", "/domains", key)).data;
@@ -385,7 +413,7 @@ async function provisionWebhook(fullKey, endpoint, events = RESEND_TRANSACTIONAL
385
413
  await deleteWebhook(fullKey, stale.id);
386
414
  }
387
415
  const created = await createWebhook(fullKey, { endpoint, events: [...events] });
388
- return created.signing_secret;
416
+ return { id: created.id, secret: created.signing_secret };
389
417
  }
390
418
  var RESET = "\x1B[0m";
391
419
  var BOLD = "\x1B[1m";
@@ -567,10 +595,11 @@ function isStepFresh(state, name, ttlHours) {
567
595
  function checkConcurrentRun(state) {
568
596
  const updated = new Date(state.updatedAt).getTime();
569
597
  const ageMs = Date.now() - updated;
570
- if (ageMs < PID_WARN_WINDOW_MS && state.lastPid !== 0 && state.lastPid !== process.pid) {
571
- return { stale: false, otherPid: state.lastPid };
598
+ const hasOtherPid = typeof state.lastPid === "number" && Number.isFinite(state.lastPid) && state.lastPid !== 0 && state.lastPid !== process.pid;
599
+ if (ageMs < PID_WARN_WINDOW_MS && hasOtherPid) {
600
+ return { active: true, otherPid: state.lastPid };
572
601
  }
573
- return { stale: true };
602
+ return { active: false };
574
603
  }
575
604
  function fingerprint(value) {
576
605
  let h = 0;
@@ -620,13 +649,13 @@ async function statusResend() {
620
649
  detail: "no env var"
621
650
  };
622
651
  }
623
- const access14 = await probeAccess(k);
624
- if (access14 === "full") return { name: "Resend", what: "full-access API key", status: "ok" };
652
+ const access17 = await probeAccess(k);
653
+ if (access17 === "full") return { name: "Resend", what: "full-access API key", status: "ok" };
625
654
  return {
626
655
  name: "Resend",
627
656
  what: "full-access API key in RESEND_FULL_ACCESS_KEY env",
628
657
  status: "missing",
629
- detail: access14 === "sending" ? "key has only sending access" : "key invalid"
658
+ detail: access17 === "sending" ? "key has only sending access" : "key invalid"
630
659
  };
631
660
  }
632
661
  var ROW_APPLE = {
@@ -681,7 +710,7 @@ async function walkDomain() {
681
710
  lines: [
682
711
  `${BOLD}what:${RESET} a domain you control DNS for`,
683
712
  `${BOLD}where:${RESET} any registrar (Cloudflare, GoDaddy, Route 53, Namecheap, Vercel, \u2026)`,
684
- `${BOLD}notes:${RESET} after \`bunx vexpo resend\`, you'll add SPF/DKIM/DMARC records at your registrar`,
713
+ `${BOLD}notes:${RESET} after \`npx vexpo resend\`, you'll add SPF/DKIM/DMARC records at your registrar`,
685
714
  " Resend's dashboard shows them and verifies. vexpo doesn't automate this.",
686
715
  "vexpo doesn't register domains for you."
687
716
  ],
@@ -706,23 +735,26 @@ async function walkConvex() {
706
735
  lines: [
707
736
  `${BOLD}what:${RESET} logged-in Convex CLI session`,
708
737
  `${BOLD}where:${RESET} free tier at dashboard.convex.dev (instant signup)`,
709
- `${BOLD}how:${RESET} \`bunx convex login\` (browser-based OAuth)`
738
+ `${BOLD}how:${RESET} \`npx convex login\` (browser-based OAuth)`
710
739
  ],
711
740
  urls: [{ label: "dashboard", url: "https://dashboard.convex.dev" }]
712
741
  });
713
742
  if (!process.stdin.isTTY) {
714
- bad("non-TTY: run `bunx convex login` then re-run");
743
+ bad("non-TTY: run `npx convex login` then re-run");
715
744
  return;
716
745
  }
717
746
  if (await askYesNo(`Run \`${dlx()} convex login\` now?`, false)) {
718
747
  const proc = spawn([dlx(), "convex", "login"], {
719
748
  stdio: ["inherit", "inherit", "inherit"]
720
749
  });
721
- if (await proc.exited !== 0) fail("convex login did not complete");
750
+ if (await proc.exited !== 0) {
751
+ yep("convex login did not complete; run `npx convex login` later");
752
+ return;
753
+ }
722
754
  if ((await statusConvex()).status === "ok") ok("Convex authenticated");
723
- else fail("still not signed in");
755
+ else yep("still not signed in; run `npx convex login` later");
724
756
  } else {
725
- nop("`bunx convex login` will prompt automatically when `bunx vexpo convex` runs");
757
+ nop("`npx convex login` will prompt automatically when `npx vexpo convex` runs");
726
758
  }
727
759
  }
728
760
  async function walkExpo() {
@@ -736,7 +768,7 @@ async function walkExpo() {
736
768
  lines: [
737
769
  `${BOLD}what:${RESET} logged-in EAS CLI session`,
738
770
  `${BOLD}where:${RESET} free tier at expo.dev/signup (instant signup)`,
739
- `${BOLD}how:${RESET} \`bunx eas login\` (browser-based OAuth)`
771
+ `${BOLD}how:${RESET} \`npx eas login\` (browser-based OAuth)`
740
772
  ],
741
773
  urls: [
742
774
  { label: "signup", url: "https://expo.dev/signup" },
@@ -744,17 +776,20 @@ async function walkExpo() {
744
776
  ]
745
777
  });
746
778
  if (!process.stdin.isTTY) {
747
- bad("non-TTY: run `bunx eas login` then re-run");
779
+ bad("non-TTY: run `npx eas login` then re-run");
748
780
  return;
749
781
  }
750
782
  if (await askYesNo(`Run \`${dlx()} eas login\` now?`, false)) {
751
783
  const proc = spawn([dlx(), "eas", "login"], { stdio: ["inherit", "inherit", "inherit"] });
752
- if (await proc.exited !== 0) fail("eas login did not complete");
784
+ if (await proc.exited !== 0) {
785
+ yep("eas login did not complete; run `npx eas login` later");
786
+ return;
787
+ }
753
788
  const after = await statusExpo();
754
789
  if (after.status === "ok") ok(`signed in as ${after.detail}`);
755
- else fail("still not signed in");
790
+ else yep("still not signed in; run `npx eas login` later");
756
791
  } else {
757
- nop("`bunx eas login` will prompt automatically when the EAS phase of `bunx vexpo full` runs");
792
+ nop("`npx eas login` will prompt automatically when the EAS phase of `npx vexpo full` runs");
758
793
  }
759
794
  }
760
795
  async function walkResend() {
@@ -770,14 +805,14 @@ async function walkResend() {
770
805
  `${BOLD}where:${RESET} free tier at resend.com (instant signup)`,
771
806
  `${BOLD}how:${RESET} Create API Key \u2192 Permission: ${BOLD}Full Access${RESET} \u2192 copy \u2192 export`,
772
807
  `${BOLD}notes:${RESET} used once to provision a scoped sending key, then discarded.`,
773
- " `bunx vexpo resend` will prompt for it interactively if env isn't set."
808
+ " `npx vexpo resend` will prompt for it interactively if env isn't set."
774
809
  ],
775
810
  urls: [
776
811
  { label: "signup", url: "https://resend.com/signup" },
777
812
  { label: "API keys", url: "https://resend.com/api-keys" }
778
813
  ]
779
814
  });
780
- nop("`bunx vexpo resend` handles the key prompt. Nothing to do here");
815
+ nop("`npx vexpo resend` handles the key prompt. Nothing to do here");
781
816
  }
782
817
  async function runAccounts(options2) {
783
818
  try {
@@ -823,7 +858,7 @@ async function runAccounts(options2) {
823
858
  ` where: ${DIM}https://developer.apple.com/account/resources/authkeys/list${RESET}`
824
859
  );
825
860
  note(
826
- `${BOLD}DNS records${RESET} added at your registrar after \`bunx vexpo resend\``
861
+ `${BOLD}DNS records${RESET} added at your registrar after \`npx vexpo resend\``
827
862
  );
828
863
  note(` Resend dashboard shows them + verifies them`);
829
864
  await recordStep("accounts", {
@@ -841,184 +876,658 @@ async function runAccounts(options2) {
841
876
  return 1;
842
877
  }
843
878
  }
844
- function expandTilde(p) {
845
- if (p === "~") return homedir();
846
- if (p.startsWith("~/")) return `${homedir()}${p.slice(1)}`;
847
- return p;
879
+ async function readJsonOrNull(path) {
880
+ try {
881
+ return JSON.parse(await readFile(path, "utf8"));
882
+ } catch {
883
+ return null;
884
+ }
848
885
  }
849
-
850
- // src/commands/apple/credentials.ts
851
- async function loadAscFromState() {
852
- const state = await load();
853
- const rec = state.steps["asc-key"];
854
- if (!rec?.outputs) return null;
855
- const out = rec.outputs;
856
- const issuerId = out.issuerId;
857
- const keyId = out.keyId;
858
- const rawPath = out.p8Path;
859
- if (!issuerId || !keyId || !rawPath) return null;
860
- const p8Path = expandTilde(rawPath);
861
- if (!existsSync(p8Path)) return null;
862
- return { issuerId, keyId, p8Path };
886
+ async function readTextOrNull(path) {
887
+ try {
888
+ await access(path);
889
+ return await readFile(path, "utf8");
890
+ } catch {
891
+ return null;
892
+ }
863
893
  }
864
- async function runAppleCredentials(options2) {
865
- section("EAS iOS credentials");
866
- const profile = options2.profile ?? "production";
867
- const asc = await loadAscFromState();
868
- if (!asc) {
869
- yep("no cached ASC creds. Run `vexpo apple asc-key` first to validate one.");
870
- return 1;
894
+ async function pkgName() {
895
+ const pkg = await readJsonOrNull("package.json");
896
+ return typeof pkg?.name === "string" && pkg.name ? pkg.name : "app";
897
+ }
898
+ async function appName() {
899
+ const text = await readTextOrNull("app.config.ts");
900
+ if (text) {
901
+ const ternary = /name:\s*IS_DEV\s*\?\s*"[^"]+"\s*:\s*"([^"]+)"/.exec(text)?.[1];
902
+ if (ternary) return ternary;
903
+ const quoted = /\bname:\s*["']([^"']+)["']/.exec(text)?.[1];
904
+ if (quoted) return quoted;
871
905
  }
872
- ok(`cached ASC API key found in state.json`);
873
- note(` issuerId: ${BOLD}${asc.issuerId}${RESET}`);
874
- note(` keyId: ${BOLD}${asc.keyId}${RESET}`);
875
- note(` .p8: ${BOLD}${asc.p8Path}${RESET}`);
876
- line();
877
- note("eas-cli's credentials wizard is interactive (no non-interactive path).");
878
- note("It walks through:");
879
- note(` 1. ${BOLD}App Store Connect: Manage your API Key${RESET}, Set up a new key`);
880
- note(" paste the 3 values above when prompted");
881
- note(` 2. ${BOLD}Build Credentials${RESET}, generate dist cert + provisioning profile`);
882
- note(` 3. ${BOLD}Push Notifications${RESET}, generate APNs key`);
883
- note("");
884
- note("After this, every `eas build` + `eas submit` works without Apple Developer");
885
- note("login prompts. Existing creds are detected and reused.");
886
- line();
887
- if (process.stdin.isTTY) {
888
- if (!await askYesNo(`Run \`eas credentials -p ios -e ${profile}\` now?`, true)) {
889
- nop("skipped (run `bunx eas credentials -p ios` later)");
890
- return 0;
906
+ const name = await pkgName();
907
+ const clean = name.replace(/^@[^/]+\//, "");
908
+ const parts = clean.split(/[-_]/).filter(Boolean);
909
+ if (parts.length === 0) return "App";
910
+ return parts.map((w) => (w[0] ?? "").toUpperCase() + w.slice(1)).join(" ");
911
+ }
912
+ async function scheme() {
913
+ const text = await readTextOrNull("app.config.ts");
914
+ if (!text) return "app";
915
+ return /scheme:\s*["']([^"']+)["']/.exec(text)?.[1] ?? "app";
916
+ }
917
+ async function bundleIdFallback() {
918
+ const text = await readTextOrNull("app.config.ts");
919
+ if (!text) return null;
920
+ return /EXPO_PUBLIC_APP_BUNDLE_ID\s*\?\?\s*"([^"]+)"/.exec(text)?.[1] ?? null;
921
+ }
922
+ async function appleTeamIdFallback() {
923
+ const text = await readTextOrNull("app.config.ts");
924
+ if (!text) return null;
925
+ const value = /EXPO_PUBLIC_APPLE_TEAM_ID\s*\?\?\s*"([^"]+)"/.exec(text)?.[1] ?? null;
926
+ if (!value || value === "ABCDE12345") return null;
927
+ return value;
928
+ }
929
+
930
+ // src/commands/better-auth.ts
931
+ function base64Secret() {
932
+ const buf = new Uint8Array(32);
933
+ crypto.getRandomValues(buf);
934
+ return btoa(String.fromCharCode(...buf));
935
+ }
936
+ async function runBetterAuth(options2) {
937
+ section("Better Auth env");
938
+ try {
939
+ const env2 = await envMap();
940
+ const siteUrl = options2.siteUrl ?? `${await scheme()}://`;
941
+ if (env2.has("SITE_URL") && env2.get("SITE_URL") === siteUrl) {
942
+ nop(`SITE_URL already set to ${siteUrl}`);
943
+ } else {
944
+ await envSet("SITE_URL", siteUrl);
945
+ ok(`set SITE_URL=${siteUrl}`);
891
946
  }
892
- } else {
893
- nop("non-TTY: skipping interactive wizard");
947
+ if (env2.has("BETTER_AUTH_SECRET") && !options2.rotateSecret) {
948
+ nop("BETTER_AUTH_SECRET already set (use --rotate-secret to regenerate)");
949
+ } else {
950
+ await envSet("BETTER_AUTH_SECRET", base64Secret());
951
+ ok(
952
+ options2.rotateSecret === true ? "rotated BETTER_AUTH_SECRET (sessions invalidated)" : "generated BETTER_AUTH_SECRET"
953
+ );
954
+ }
955
+ const desiredAppName = options2.appName ?? await appName();
956
+ if (env2.has("APP_NAME") && env2.get("APP_NAME") === desiredAppName) {
957
+ nop(`APP_NAME already set to ${desiredAppName}`);
958
+ } else {
959
+ await envSet("APP_NAME", desiredAppName);
960
+ ok(`set APP_NAME=${desiredAppName}`);
961
+ }
962
+ await recordStep("better-auth", {
963
+ siteUrl,
964
+ appName: desiredAppName,
965
+ rotated: options2.rotateSecret === true
966
+ });
894
967
  return 0;
968
+ } catch (err) {
969
+ bad(err instanceof Error ? err.message : String(err));
970
+ return 1;
895
971
  }
896
- const env2 = {
897
- ...process.env,
898
- EXPO_ASC_API_KEY_PATH: asc.p8Path,
899
- EXPO_ASC_KEY_ID: asc.keyId,
900
- EXPO_ASC_ISSUER_ID: asc.issuerId
901
- };
902
- const proc = spawn([dlx(), "eas", "credentials:configure-build", "-p", "ios", "-e", profile], {
903
- stdin: "inherit",
904
- stdout: "inherit",
905
- stderr: "inherit",
906
- env: env2
907
- });
908
- const code = await proc.exited;
909
- if (code !== 0) {
910
- bad(`eas credentials exited with code ${code}`);
911
- return code;
912
- }
913
- await recordStep("apple-credentials", {
914
- profile,
915
- configuredAt: (/* @__PURE__ */ new Date()).toISOString(),
916
- ascIssuerId: asc.issuerId,
917
- ascKeyId: asc.keyId
918
- });
919
- line();
920
- ok("EAS credentials configured");
921
- yep(`next: ${BOLD}bun run eas:dev:device${RESET} to build the dev client on a registered device`);
922
- return 0;
923
972
  }
924
- async function readPrivateKey(source) {
925
- if ("contents" in source) return source.contents;
926
- const path = expandTilde(source.path);
973
+ var BASE2 = `${process.env.CONVEX_PROVISION_HOST || "https://api.convex.dev"}/v1`;
974
+ async function accessToken() {
927
975
  try {
928
- await access(path);
976
+ const raw = await readFile(join(homedir(), ".convex", "config.json"), "utf8");
977
+ const token = JSON.parse(raw).accessToken;
978
+ return token && token.length > 0 ? token : null;
929
979
  } catch {
930
- throw new Error(`p8 file not found at ${path}`);
980
+ return null;
931
981
  }
932
- return readFile(path, "utf8");
933
982
  }
934
- async function signClientSecret(opts) {
935
- const days = opts.expirationDays;
936
- const now = Math.floor(Date.now() / 1e3);
937
- const header = { alg: "ES256", kid: opts.keyId };
938
- const payload = {
939
- iss: opts.teamId,
940
- iat: now,
941
- exp: now + days * 86400,
942
- aud: "https://appleid.apple.com",
943
- sub: opts.servicesId
944
- };
945
- const privateKey = await readPrivateKey(opts.privateKey);
946
- const headerB64 = Buffer.from(JSON.stringify(header)).toString("base64url");
947
- const payloadB64 = Buffer.from(JSON.stringify(payload)).toString("base64url");
948
- const signingInput = `${headerB64}.${payloadB64}`;
949
- const signer = createSign("SHA256");
950
- signer.update(signingInput);
951
- signer.end();
952
- const signature = signer.sign({ key: privateKey, dsaEncoding: "ieee-p1363" }).toString("base64url");
953
- return `${signingInput}.${signature}`;
983
+ async function checkToken() {
984
+ const token = await accessToken();
985
+ if (!token) return "no-token";
986
+ const ctl = new AbortController();
987
+ const timer = setTimeout(() => ctl.abort(), 8e3);
988
+ try {
989
+ const res = await fetch(`${BASE2}/list_personal_access_tokens`, {
990
+ headers: { Authorization: `Bearer ${token}`, "Convex-Client": "vexpo-cli" },
991
+ signal: ctl.signal
992
+ });
993
+ return res.status === 401 || res.status === 403 ? "unauthorized" : "valid";
994
+ } catch {
995
+ return "valid";
996
+ } finally {
997
+ clearTimeout(timer);
998
+ }
954
999
  }
955
- var ENV_FILE = ".env.local";
956
- async function fileExists2(p) {
1000
+ async function get(token, path) {
1001
+ const ctl = new AbortController();
1002
+ const timer = setTimeout(() => ctl.abort(), 1e4);
957
1003
  try {
958
- await access(p);
959
- return true;
1004
+ const res = await fetch(`${BASE2}${path}`, {
1005
+ headers: { Authorization: `Bearer ${token}`, "Convex-Client": "vexpo-cli" },
1006
+ signal: ctl.signal
1007
+ });
1008
+ if (!res.ok) return null;
1009
+ return await res.json();
960
1010
  } catch {
961
- return false;
1011
+ return null;
1012
+ } finally {
1013
+ clearTimeout(timer);
962
1014
  }
963
1015
  }
964
- async function readAll() {
965
- const out = /* @__PURE__ */ new Map();
966
- if (!await fileExists2(ENV_FILE)) return out;
967
- const text = (await readFile(ENV_FILE, "utf8")).replace(/^/, "").replace(/\r\n/g, "\n");
968
- const lines = text.split("\n");
969
- for (let i = 0; i < lines.length; i++) {
970
- const raw = lines[i];
971
- const trimmed = raw.trim();
972
- if (!trimmed || trimmed.startsWith("#")) continue;
973
- const eq = trimmed.indexOf("=");
974
- if (eq <= 0) continue;
975
- const key = trimmed.slice(0, eq).trim();
976
- let value = trimmed.slice(eq + 1);
977
- const startQuote = value.match(/^\s*(['"])/);
978
- if (startQuote) {
979
- const quote = startQuote[1];
980
- let rest = value.replace(/^\s*['"]/, "");
981
- let endIdx = rest.indexOf(quote);
982
- while (endIdx === -1 && i + 1 < lines.length) {
983
- i += 1;
984
- rest += `
985
- ${lines[i]}`;
986
- endIdx = rest.indexOf(quote);
987
- }
988
- value = endIdx === -1 ? rest : rest.slice(0, endIdx);
989
- out.set(key, value);
990
- continue;
1016
+ async function post(token, path, body) {
1017
+ const ctl = new AbortController();
1018
+ const timer = setTimeout(() => ctl.abort(), 15e3);
1019
+ try {
1020
+ const res = await fetch(`${BASE2}${path}`, {
1021
+ method: "POST",
1022
+ headers: {
1023
+ Authorization: `Bearer ${token}`,
1024
+ "Convex-Client": "vexpo-cli",
1025
+ "Content-Type": "application/json"
1026
+ },
1027
+ body: JSON.stringify(body),
1028
+ signal: ctl.signal
1029
+ });
1030
+ const text = await res.text();
1031
+ if (!res.ok) {
1032
+ throw new Error(`Convex Platform POST ${path} \u2192 ${res.status}: ${text.slice(0, 200)}`);
991
1033
  }
992
- value = value.trim();
993
- const hashAt = value.search(/\s#/);
994
- if (hashAt >= 0) value = value.slice(0, hashAt).trim();
995
- out.set(key, value);
1034
+ return text ? JSON.parse(text) : void 0;
1035
+ } finally {
1036
+ clearTimeout(timer);
996
1037
  }
997
- return out;
998
1038
  }
999
- async function readOne(key) {
1000
- return (await readAll()).get(key);
1039
+ async function requireToken() {
1040
+ const token = await accessToken();
1041
+ if (!token) throw new Error("not logged in to Convex (no ~/.convex/config.json accessToken)");
1042
+ return token;
1001
1043
  }
1002
- async function ensureLine(key, value) {
1003
- const current = await fileExists2(ENV_FILE) ? await readFile(ENV_FILE, "utf8") : "";
1004
- if (new RegExp(`^${key}=`, "m").test(current)) return;
1005
- const needsNewline = current !== "" && !current.endsWith("\n");
1006
- await writeFile(ENV_FILE, `${current}${needsNewline ? "\n" : ""}${key}=${value}
1007
- `);
1044
+ async function mintDeployKey(deploymentName2, opts) {
1045
+ const token = await requireToken();
1046
+ const body = { name: opts?.name ?? "vexpo" };
1047
+ if (opts?.expiresAtMs) {
1048
+ if (opts.expiresAtMs < Date.now() + 30 * 6e4) {
1049
+ throw new Error("deploy key expiresAtMs must be at least 30 minutes in the future");
1050
+ }
1051
+ body.expiresAt = opts.expiresAtMs;
1052
+ }
1053
+ const res = await post(
1054
+ token,
1055
+ `/deployments/${deploymentName2}/create_deploy_key`,
1056
+ body
1057
+ );
1058
+ if (!res?.deployKey) throw new Error("create_deploy_key returned no deployKey");
1059
+ return res.deployKey;
1060
+ }
1061
+ async function resolveProdDeployment(anyDeploymentName) {
1062
+ const deployments = await listProjectDeployments(anyDeploymentName);
1063
+ if (!deployments) return null;
1064
+ const prods = deploymentsOfType(deployments, "prod");
1065
+ return (prods.find((d) => d.isDefault) ?? prods[0])?.name ?? null;
1066
+ }
1067
+ async function mintProdDeployKey(anyDeploymentName, name = "vexpo") {
1068
+ const deployment = await resolveProdDeployment(anyDeploymentName);
1069
+ if (!deployment) return null;
1070
+ return { key: await mintDeployKey(deployment, { name }), deployment };
1071
+ }
1072
+ async function listProjectDeployments(deploymentName2) {
1073
+ const token = await accessToken();
1074
+ if (!token) return null;
1075
+ const dep = await get(token, `/deployments/${deploymentName2}`);
1076
+ if (!dep?.projectId) return null;
1077
+ const list = await get(
1078
+ token,
1079
+ `/projects/${dep.projectId}/list_deployments`
1080
+ );
1081
+ return Array.isArray(list) ? list : null;
1082
+ }
1083
+ function deploymentsOfType(deployments, type) {
1084
+ return deployments.filter((d) => d.deploymentType === type);
1008
1085
  }
1009
- async function removeLines(keys) {
1010
- if (!await fileExists2(ENV_FILE)) return;
1011
- const text = await readFile(ENV_FILE, "utf8");
1012
- const drop = new Set(keys);
1013
- const next = text.split("\n").filter((l) => {
1014
- const eq = l.indexOf("=");
1015
- if (eq <= 0) return true;
1016
- return !drop.has(l.slice(0, eq).trim());
1017
- }).join("\n").replace(/\n{3,}/g, "\n\n");
1018
- await writeFile(ENV_FILE, next);
1086
+ function describeDeployment(d) {
1087
+ return d.reference ? `${d.name} (${d.reference})` : d.name;
1088
+ }
1089
+
1090
+ // src/commands/convex.ts
1091
+ var BUNDLE_ID_RE = /^[A-Za-z0-9.-]+$/;
1092
+ var TEAM_ID_RE = /^[A-Z0-9]{10}$/;
1093
+ function planConvexDev(options2, needsProvisioning, projectName) {
1094
+ const devArgs = ["convex", "dev", "--once", "--tail-logs", "disable"];
1095
+ if (needsProvisioning) {
1096
+ devArgs.push("--configure", "new", "--project", projectName);
1097
+ devArgs.push("--dev-deployment", options2.local ? "local" : "cloud");
1098
+ }
1099
+ return { selectLocalFirst: !!options2.local && !needsProvisioning, devArgs };
1100
+ }
1101
+ async function runConvex(options2) {
1102
+ section("Convex deployment");
1103
+ try {
1104
+ const tokenStatus = await checkToken();
1105
+ if (tokenStatus !== "valid") {
1106
+ yep(
1107
+ tokenStatus === "no-token" ? "not signed in to Convex" : "Convex token expired or revoked"
1108
+ );
1109
+ await helpAndWait({
1110
+ body: "Sign in (or refresh) with `npx convex login` in another terminal:",
1111
+ urls: [
1112
+ { label: "Convex sign-up", url: "https://convex.dev" },
1113
+ { label: "Convex dashboard", url: "https://dashboard.convex.dev" }
1114
+ ],
1115
+ allowSkip: true,
1116
+ skipLabel: "skip"
1117
+ });
1118
+ }
1119
+ const localEnv = await readAll();
1120
+ const existing = localEnv.get("CONVEX_DEPLOYMENT");
1121
+ if (options2.fresh) {
1122
+ await removeLines([
1123
+ "CONVEX_DEPLOYMENT",
1124
+ "EXPO_PUBLIC_CONVEX_URL",
1125
+ "EXPO_PUBLIC_CONVEX_SITE_URL"
1126
+ ]);
1127
+ }
1128
+ const needsProvisioning = options2.fresh === true || !existing;
1129
+ const projectName = options2.name ?? await pkgName();
1130
+ const plan = planConvexDev(options2, needsProvisioning, projectName);
1131
+ if (plan.selectLocalFirst) {
1132
+ const sel = spawn([dlx(), "convex", "deployment", "select", "local"], {
1133
+ stdin: "inherit",
1134
+ stdout: "inherit",
1135
+ stderr: "inherit"
1136
+ });
1137
+ if (await sel.exited !== 0) {
1138
+ bad("convex deployment select local failed");
1139
+ return 1;
1140
+ }
1141
+ }
1142
+ const cmd = [dlx(), ...plan.devArgs];
1143
+ if (needsProvisioning) {
1144
+ ok(`provisioning Convex project '${projectName}'`);
1145
+ } else {
1146
+ ok(`connecting to existing deployment ${existing}`);
1147
+ }
1148
+ const proc = spawn(cmd, { stdin: "inherit", stdout: "inherit", stderr: "inherit" });
1149
+ if (await proc.exited !== 0) {
1150
+ bad("convex dev exited with a non-zero code");
1151
+ return 1;
1152
+ }
1153
+ const refreshed = await readAll();
1154
+ const deployment = refreshed.get("CONVEX_DEPLOYMENT");
1155
+ if (!deployment) {
1156
+ bad("CONVEX_DEPLOYMENT missing after convex dev ran");
1157
+ return 1;
1158
+ }
1159
+ const slug2 = deployment.split("#")[0].trim().split(":")[1];
1160
+ if (!slug2) {
1161
+ bad(`invalid CONVEX_DEPLOYMENT: ${deployment}`);
1162
+ return 1;
1163
+ }
1164
+ process.env.CONVEX_DEPLOYMENT = deployment;
1165
+ if (refreshed.has("EXPO_PUBLIC_CONVEX_URL")) {
1166
+ nop("EXPO_PUBLIC_CONVEX_URL already set");
1167
+ } else {
1168
+ await ensureLine("EXPO_PUBLIC_CONVEX_URL", `https://${slug2}.convex.cloud`);
1169
+ ok("wrote EXPO_PUBLIC_CONVEX_URL");
1170
+ }
1171
+ if (refreshed.has("EXPO_PUBLIC_CONVEX_SITE_URL")) {
1172
+ nop("EXPO_PUBLIC_CONVEX_SITE_URL already set");
1173
+ } else {
1174
+ await ensureLine("EXPO_PUBLIC_CONVEX_SITE_URL", `https://${slug2}.convex.site`);
1175
+ ok("wrote EXPO_PUBLIC_CONVEX_SITE_URL");
1176
+ }
1177
+ if (refreshed.has("EXPO_PUBLIC_SITE_URL")) {
1178
+ nop("EXPO_PUBLIC_SITE_URL already set");
1179
+ } else {
1180
+ const s = `${await scheme()}://`;
1181
+ await ensureLine("EXPO_PUBLIC_SITE_URL", s);
1182
+ ok(`wrote EXPO_PUBLIC_SITE_URL=${s}`);
1183
+ }
1184
+ await ensureIdentity(refreshed);
1185
+ await recordStep("convex", {
1186
+ deployment,
1187
+ slug: slug2,
1188
+ ...options2.local ? { local: true } : {}
1189
+ });
1190
+ line();
1191
+ ok(`Convex deployment ready: ${BOLD}${slug2}${RESET}`);
1192
+ note(`dashboard: https://dashboard.convex.dev/d/${slug2}`);
1193
+ return 0;
1194
+ } catch (err) {
1195
+ bad(err instanceof Error ? err.message : String(err));
1196
+ return 1;
1197
+ }
1198
+ }
1199
+ async function ensureIdentity(localEnv) {
1200
+ const haveBundle = localEnv.has("EXPO_PUBLIC_APP_BUNDLE_ID");
1201
+ const haveTeam = localEnv.has("EXPO_PUBLIC_APPLE_TEAM_ID");
1202
+ let bundleId = localEnv.get("EXPO_PUBLIC_APP_BUNDLE_ID");
1203
+ let teamId = localEnv.get("EXPO_PUBLIC_APPLE_TEAM_ID");
1204
+ if (!haveBundle) {
1205
+ if (!process.stdin.isTTY) {
1206
+ yep("EXPO_PUBLIC_APP_BUNDLE_ID not set (non-TTY); skipping prompt");
1207
+ yep("set it in .env.local before running `vexpo apple` or building");
1208
+ } else {
1209
+ const fromConfig = await bundleIdFallback();
1210
+ const isTemplate = !fromConfig || fromConfig.startsWith("com.example.");
1211
+ const suggested = isTemplate ? `com.example.${await pkgName()}` : fromConfig;
1212
+ const cachedHint = isTemplate ? "" : ` ${DIM}(from app.config.ts)${RESET}`;
1213
+ const raw = (await ask(
1214
+ ` iOS bundle id ${DIM}(reverse-DNS, e.g. com.you.app)${RESET}${cachedHint}
1215
+ ${DIM}> ${suggested} ${RESET}`
1216
+ )).trim();
1217
+ bundleId = raw || suggested;
1218
+ if (!BUNDLE_ID_RE.test(bundleId)) {
1219
+ throw new Error(`invalid bundle id '${bundleId}' (allowed: A-Z a-z 0-9 . -)`);
1220
+ }
1221
+ await ensureLine("EXPO_PUBLIC_APP_BUNDLE_ID", bundleId);
1222
+ ok(`wrote EXPO_PUBLIC_APP_BUNDLE_ID=${bundleId}`);
1223
+ }
1224
+ } else {
1225
+ nop(`EXPO_PUBLIC_APP_BUNDLE_ID already set (${bundleId})`);
1226
+ }
1227
+ if (!haveTeam) {
1228
+ if (!process.stdin.isTTY) {
1229
+ yep("EXPO_PUBLIC_APPLE_TEAM_ID not set (non-TTY); skipping prompt");
1230
+ } else {
1231
+ const fromConfig = await appleTeamIdFallback();
1232
+ const cachedHint = fromConfig ? ` ${DIM}[${fromConfig} from app.config.ts]${RESET}` : "";
1233
+ const raw = (await ask(
1234
+ ` Apple Team id ${DIM}(10-char alphanumeric, find at developer.apple.com/account)${RESET}${cachedHint}
1235
+ ${DIM}> ${RESET}`
1236
+ )).trim().toUpperCase();
1237
+ const value = raw || (fromConfig ?? "");
1238
+ if (!TEAM_ID_RE.test(value)) {
1239
+ throw new Error(`invalid Apple Team id '${value}' (must be 10 uppercase alphanumeric)`);
1240
+ }
1241
+ teamId = value;
1242
+ await ensureLine("EXPO_PUBLIC_APPLE_TEAM_ID", teamId);
1243
+ ok(`wrote EXPO_PUBLIC_APPLE_TEAM_ID=${teamId}`);
1244
+ }
1245
+ } else {
1246
+ nop(`EXPO_PUBLIC_APPLE_TEAM_ID already set (${teamId})`);
1247
+ }
1248
+ if (bundleId) {
1249
+ await envSet("APP_BUNDLE_ID", bundleId);
1250
+ ok(`Convex env: APP_BUNDLE_ID=${bundleId}`);
1251
+ }
1252
+ if (teamId) {
1253
+ await envSet("APPLE_TEAM_ID", teamId);
1254
+ ok(`Convex env: APPLE_TEAM_ID=${teamId}`);
1255
+ }
1256
+ }
1257
+
1258
+ // src/commands/adopt.ts
1259
+ function buildFinishRunbook(s) {
1260
+ const steps = [];
1261
+ if (!s.hasResend) {
1262
+ steps.push({ cmd: "vexpo resend", desc: "provision the dev sending key + webhook" });
1263
+ }
1264
+ if (!s.hasApple) {
1265
+ steps.push({ cmd: "vexpo apple jwt", desc: "sign Apple Sign In (or --copy-from <old>)" });
1266
+ steps.push({ cmd: "vexpo asc:connect", desc: "link EAS to App Store Connect for submit" });
1267
+ }
1268
+ if (!s.hasProd) {
1269
+ steps.push({ cmd: "npx convex deploy", desc: "provision + push to the prod deployment" });
1270
+ }
1271
+ steps.push({
1272
+ cmd: `vexpo convex:migrate --from ${s.devSlug} --prod`,
1273
+ desc: "mirror server-side env onto prod"
1274
+ });
1275
+ steps.push({ cmd: "vexpo env convex-key", desc: "sync the deploy key + selector to EAS" });
1276
+ if (!s.hasEasProdUrl) {
1277
+ steps.push({ cmd: "vexpo full", desc: "push prod/preview EAS env (or `vexpo eas`)" });
1278
+ }
1279
+ steps.push({ cmd: "vexpo doctor --channel prod", desc: "verify the whole chain" });
1280
+ return steps;
1281
+ }
1282
+ async function runAdopt(options2) {
1283
+ section("Adopt");
1284
+ const deploymentRef = await readOne("CONVEX_DEPLOYMENT");
1285
+ if (!deploymentRef) {
1286
+ bad("no CONVEX_DEPLOYMENT in .env.local. nothing to adopt");
1287
+ note("run `eas integrations:convex:connect` first, or `vexpo full` to provision from scratch");
1288
+ return 1;
1289
+ }
1290
+ const devSlug = deploymentSlug(deploymentRef);
1291
+ if (!devSlug) {
1292
+ bad(`could not parse a deployment slug from CONVEX_DEPLOYMENT=${deploymentRef}`);
1293
+ return 1;
1294
+ }
1295
+ ok(`adopting Convex deployment: ${BOLD}${devSlug}${RESET}`);
1296
+ const deployments = await listProjectDeployments(devSlug);
1297
+ let prodSlug;
1298
+ if (deployments) {
1299
+ line();
1300
+ note("project deployments:");
1301
+ for (const d of deployments) {
1302
+ const mine = d.name === devSlug ? ` ${DIM}\u2190 .env.local${RESET}` : "";
1303
+ note(` ${describeDeployment(d)} ${DIM}[${d.deploymentType}]${RESET}${mine}`);
1304
+ }
1305
+ const devs = deploymentsOfType(deployments, "dev");
1306
+ if (devs.length > 1) {
1307
+ yep(
1308
+ `${devs.length} dev deployments; pick one canonical and delete the rest in the dashboard`
1309
+ );
1310
+ }
1311
+ prodSlug = deploymentsOfType(deployments, "prod")[0]?.name;
1312
+ } else {
1313
+ nop("deployment enumeration unavailable (offline or not logged in); continuing");
1314
+ }
1315
+ if (!options2.skipDevSteps) {
1316
+ line();
1317
+ const code = await runConvex({});
1318
+ if (code !== 0) return code;
1319
+ const devEnv2 = await envMap();
1320
+ if (!devEnv2.has("BETTER_AUTH_SECRET")) {
1321
+ const baCode = await runBetterAuth({});
1322
+ if (baCode !== 0) return baCode;
1323
+ } else {
1324
+ nop("BETTER_AUTH_SECRET already set on the dev deployment");
1325
+ }
1326
+ }
1327
+ const devEnv = await envMap();
1328
+ const projectId = await resolveProjectId();
1329
+ const easProd = projectId ? await envList("production").catch(() => null) : null;
1330
+ const steps = buildFinishRunbook({
1331
+ devSlug,
1332
+ hasResend: devEnv.has("RESEND_API_KEY"),
1333
+ hasApple: devEnv.has("APPLE_CLIENT_SECRET"),
1334
+ hasProd: !!prodSlug,
1335
+ hasEasProdUrl: !!easProd?.has("EXPO_PUBLIC_CONVEX_URL")
1336
+ });
1337
+ line();
1338
+ section("Finish");
1339
+ note("adopted the dev deployment. remaining, in order:");
1340
+ const width = Math.max(...steps.map((s) => s.cmd.length));
1341
+ for (const s of steps) note(` ${BOLD}${s.cmd.padEnd(width)}${RESET} ${DIM}${s.desc}${RESET}`);
1342
+ line();
1343
+ nop("prod + Apple + Resend legs need credentials/prompts, so they're listed, not auto-run");
1344
+ return 0;
1345
+ }
1346
+ function expandTilde(p) {
1347
+ if (p === "~") return homedir();
1348
+ if (p.startsWith("~/")) return `${homedir()}${p.slice(1)}`;
1349
+ return p;
1350
+ }
1351
+
1352
+ // src/commands/apple/credentials.ts
1353
+ async function resolveBundleId(profile) {
1354
+ const fromConfig = await bundleIdFallback();
1355
+ if (fromConfig && !fromConfig.startsWith("com.example.")) {
1356
+ return { source: "app.config.ts", value: fromConfig, templatePlaceholder: false };
1357
+ }
1358
+ try {
1359
+ const env2 = await envList(profile);
1360
+ const fromEnv = env2.get("EXPO_PUBLIC_APP_BUNDLE_ID");
1361
+ if (fromEnv && !fromEnv.startsWith("com.example.")) {
1362
+ return { source: "EAS env", value: fromEnv, templatePlaceholder: false };
1363
+ }
1364
+ } catch {
1365
+ }
1366
+ return {
1367
+ source: fromConfig ? "app.config.ts" : null,
1368
+ value: fromConfig,
1369
+ templatePlaceholder: true
1370
+ };
1371
+ }
1372
+ async function loadAscFromState() {
1373
+ const state = await load();
1374
+ const rec = state.steps["asc-key"];
1375
+ if (!rec?.outputs) return null;
1376
+ const out = rec.outputs;
1377
+ const issuerId = out.issuerId;
1378
+ const keyId = out.keyId;
1379
+ const rawPath = out.p8Path;
1380
+ if (!issuerId || !keyId || !rawPath) return null;
1381
+ const p8Path = expandTilde(rawPath);
1382
+ if (!existsSync(p8Path)) return null;
1383
+ return { issuerId, keyId, p8Path };
1384
+ }
1385
+ async function runAppleCredentials(options2) {
1386
+ section("EAS iOS credentials");
1387
+ const profile = options2.profile ?? "production";
1388
+ const asc = await loadAscFromState();
1389
+ if (!asc) {
1390
+ yep("no cached ASC creds. Run `vexpo apple asc-key` first to validate one.");
1391
+ return 1;
1392
+ }
1393
+ ok(`cached ASC API key found in state.json`);
1394
+ note(` issuerId: ${BOLD}${asc.issuerId}${RESET}`);
1395
+ note(` keyId: ${BOLD}${asc.keyId}${RESET}`);
1396
+ note(` .p8: ${BOLD}${asc.p8Path}${RESET}`);
1397
+ const bundle = await resolveBundleId(profile);
1398
+ if (bundle.templatePlaceholder) {
1399
+ line();
1400
+ bad("template bundle id detected, refusing to register placeholder credentials");
1401
+ note(
1402
+ bundle.value ? ` app.config.ts still defaults to ${BOLD}${bundle.value}${RESET}` : ` could not resolve a bundle id from app.config.ts`
1403
+ );
1404
+ note(` EAS env (${profile}) does not set EXPO_PUBLIC_APP_BUNDLE_ID either`);
1405
+ line();
1406
+ note("fix by running the rebrand wizard, which bakes your bundle id into app.config.ts:");
1407
+ note(` ${BOLD}npx vexpo rebrand${RESET}`);
1408
+ note("alternatively, push your local env to EAS before running this step:");
1409
+ note(` ${BOLD}npx eas env:push --environment ${profile}${RESET}`);
1410
+ return 1;
1411
+ }
1412
+ ok(`bundle id: ${BOLD}${bundle.value}${RESET} (from ${bundle.source})`);
1413
+ line();
1414
+ note("eas-cli's credentials wizard is interactive (no non-interactive path).");
1415
+ note("It walks through:");
1416
+ note(` 1. ${BOLD}App Store Connect: Manage your API Key${RESET}, Set up a new key`);
1417
+ note(" paste the 3 values above when prompted");
1418
+ note(` 2. ${BOLD}Build Credentials${RESET}, generate dist cert + provisioning profile`);
1419
+ note(` 3. ${BOLD}Push Notifications${RESET}, generate APNs key`);
1420
+ note("");
1421
+ note("After this, every `eas build` + `eas submit` works without Apple Developer");
1422
+ note("login prompts. Existing creds are detected and reused.");
1423
+ line();
1424
+ if (process.stdin.isTTY) {
1425
+ if (!await askYesNo(`Run \`eas credentials -p ios -e ${profile}\` now?`, true)) {
1426
+ nop("skipped (run `npx eas credentials -p ios` later)");
1427
+ return 0;
1428
+ }
1429
+ } else {
1430
+ nop("non-TTY: skipping interactive wizard");
1431
+ return 0;
1432
+ }
1433
+ const env2 = {
1434
+ ...process.env,
1435
+ EXPO_ASC_API_KEY_PATH: asc.p8Path,
1436
+ EXPO_ASC_KEY_ID: asc.keyId,
1437
+ EXPO_ASC_ISSUER_ID: asc.issuerId
1438
+ };
1439
+ const proc = spawn([dlx(), "eas", "credentials:configure-build", "-p", "ios", "-e", profile], {
1440
+ stdin: "inherit",
1441
+ stdout: "inherit",
1442
+ stderr: "inherit",
1443
+ env: env2
1444
+ });
1445
+ const code = await proc.exited;
1446
+ if (code !== 0) {
1447
+ bad(`eas credentials exited with code ${code}`);
1448
+ return code;
1449
+ }
1450
+ await recordStep("apple-credentials", {
1451
+ profile,
1452
+ configuredAt: (/* @__PURE__ */ new Date()).toISOString(),
1453
+ ascIssuerId: asc.issuerId,
1454
+ ascKeyId: asc.keyId
1455
+ });
1456
+ line();
1457
+ ok("EAS credentials configured");
1458
+ yep(`next: ${BOLD}npm run eas:dev:device${RESET} to build the dev client on a registered device`);
1459
+ return 0;
1460
+ }
1461
+ async function readPrivateKey(source) {
1462
+ if ("contents" in source) return source.contents;
1463
+ const path = expandTilde(source.path);
1464
+ try {
1465
+ await access(path);
1466
+ } catch {
1467
+ throw new Error(`p8 file not found at ${path}`);
1468
+ }
1469
+ return readFile(path, "utf8");
1470
+ }
1471
+ async function signClientSecret(opts) {
1472
+ const days = opts.expirationDays;
1473
+ const now = Math.floor(Date.now() / 1e3);
1474
+ const header = { alg: "ES256", kid: opts.keyId };
1475
+ const payload = {
1476
+ iss: opts.teamId,
1477
+ iat: now,
1478
+ exp: now + days * 86400,
1479
+ aud: "https://appleid.apple.com",
1480
+ sub: opts.servicesId
1481
+ };
1482
+ const privateKey = await readPrivateKey(opts.privateKey);
1483
+ const headerB64 = Buffer.from(JSON.stringify(header)).toString("base64url");
1484
+ const payloadB64 = Buffer.from(JSON.stringify(payload)).toString("base64url");
1485
+ const signingInput = `${headerB64}.${payloadB64}`;
1486
+ const signer = createSign("SHA256");
1487
+ signer.update(signingInput);
1488
+ signer.end();
1489
+ const signature = signer.sign({ key: privateKey, dsaEncoding: "ieee-p1363" }).toString("base64url");
1490
+ return `${signingInput}.${signature}`;
1019
1491
  }
1020
1492
 
1021
1493
  // src/commands/apple/jwt.ts
1494
+ var APPLE_ENV_KEYS = [
1495
+ "APPLE_CLIENT_ID",
1496
+ "APPLE_TEAM_ID",
1497
+ "APPLE_KEY_ID",
1498
+ "APPLE_CLIENT_SECRET"
1499
+ ];
1500
+ async function copyAppleEnv(from) {
1501
+ section("Apple Sign In");
1502
+ const slug2 = deploymentSlug(from) ?? from;
1503
+ const src = await envMap({ deployment: slug2 });
1504
+ const present = APPLE_ENV_KEYS.filter((k) => src.has(k) && src.get(k));
1505
+ if (present.length === 0) {
1506
+ bad(`no APPLE_* vars on deployment ${slug2} (unreachable or not provisioned)`);
1507
+ note("pass a deployment slug your account can reach, e.g. `--copy-from old-deployment-123`");
1508
+ return 1;
1509
+ }
1510
+ const dst = await envMap();
1511
+ let copied = 0;
1512
+ for (const key of present) {
1513
+ const value = src.get(key);
1514
+ if (dst.get(key) === value) {
1515
+ nop(`${key} already matches`);
1516
+ continue;
1517
+ }
1518
+ await envSet(key, value);
1519
+ ok(`copied ${key} from ${slug2}`);
1520
+ copied += 1;
1521
+ }
1522
+ line();
1523
+ ok(`Apple env copied from ${slug2} (${copied} changed)`);
1524
+ if (!present.includes("APPLE_CLIENT_SECRET")) {
1525
+ yep("source had no APPLE_CLIENT_SECRET; re-sign with `vexpo apple jwt`");
1526
+ } else {
1527
+ note("the copied client_secret keeps the source's expiry; re-sign before it lapses");
1528
+ }
1529
+ return 0;
1530
+ }
1022
1531
  async function promptOrEnv(envName, prompt) {
1023
1532
  const fromEnv = process.env[envName];
1024
1533
  if (fromEnv) return fromEnv;
@@ -1027,6 +1536,14 @@ async function promptOrEnv(envName, prompt) {
1027
1536
  return v || void 0;
1028
1537
  }
1029
1538
  async function runAppleJwt(options2) {
1539
+ if (options2.copyFrom) {
1540
+ try {
1541
+ return await copyAppleEnv(options2.copyFrom);
1542
+ } catch (err) {
1543
+ bad(err instanceof Error ? err.message : String(err));
1544
+ return 1;
1545
+ }
1546
+ }
1030
1547
  section("Apple Sign In");
1031
1548
  try {
1032
1549
  const env2 = await envMap();
@@ -1119,7 +1636,7 @@ async function runAppleJwt(options2) {
1119
1636
  });
1120
1637
  if (process.stdin.isTTY && !rotateOnly) {
1121
1638
  line();
1122
- if (await askYesNo("Schedule a calendar reminder ~150 days from now?", false)) {
1639
+ if (await askYesNo("Show the renewal date and rotate command?", false)) {
1123
1640
  const when = new Date(Date.now() + 150 * 864e5);
1124
1641
  note(`renew on or before ${when.toDateString()} by running:`);
1125
1642
  note(` ${BOLD}vexpo apple jwt --rotate${RESET}`);
@@ -1278,11 +1795,6 @@ function makeAscClient(creds) {
1278
1795
  return out;
1279
1796
  }
1280
1797
  return {
1281
- /**
1282
- * Low-level primitives. Exposed so resource-specific modules
1283
- * (asc-testflight.ts, asc-reviews.ts, asc-versions.ts, asc-sandbox.ts)
1284
- * can extend the client without re-implementing auth + retry.
1285
- */
1286
1798
  request,
1287
1799
  paginatedList,
1288
1800
  bundleIds: {
@@ -1421,8 +1933,8 @@ function makeAscClient(creds) {
1421
1933
  }
1422
1934
  async function validate(creds) {
1423
1935
  try {
1424
- const client2 = makeAscClient(creds);
1425
- const apps = await client2.apps.list();
1936
+ const client = makeAscClient(creds);
1937
+ const apps = await client.apps.list();
1426
1938
  return { ok: true, appCount: apps.length };
1427
1939
  } catch (err) {
1428
1940
  if (err instanceof AscApiError) {
@@ -1435,7 +1947,7 @@ async function validate(creds) {
1435
1947
  var SIGN_IN_WITH_APPLE_CAPABILITY = "APPLE_ID_AUTH";
1436
1948
 
1437
1949
  // src/commands/apple/asc-key.ts
1438
- async function fileExists3(p) {
1950
+ async function fileExists2(p) {
1439
1951
  try {
1440
1952
  await access(expandTilde(p));
1441
1953
  return true;
@@ -1479,7 +1991,7 @@ async function promptCredsInteractive() {
1479
1991
  return null;
1480
1992
  }
1481
1993
  const p8Path = expandTilde(rawP8);
1482
- if (!await fileExists3(p8Path)) {
1994
+ if (!await fileExists2(p8Path)) {
1483
1995
  bad(`.p8 not found at ${p8Path}`);
1484
1996
  return null;
1485
1997
  }
@@ -1490,20 +2002,12 @@ async function readEnvCreds() {
1490
2002
  const keyId = process.env.APPLE_ASC_KEY_ID;
1491
2003
  const p8Path = process.env.APPLE_ASC_P8_PATH;
1492
2004
  if (!issuerId || !keyId || !p8Path) return null;
1493
- if (!await fileExists3(p8Path)) {
2005
+ if (!await fileExists2(p8Path)) {
1494
2006
  bad(`APPLE_ASC_P8_PATH=${p8Path} not found`);
1495
2007
  return null;
1496
2008
  }
1497
2009
  return { issuerId, keyId, privateKey: { path: p8Path } };
1498
2010
  }
1499
- async function easAscStatus() {
1500
- try {
1501
- const status = await ascStatus();
1502
- return status.connected ? "connected" : "disconnected";
1503
- } catch {
1504
- return "unknown";
1505
- }
1506
- }
1507
2011
  async function readStateCreds() {
1508
2012
  const state = await load();
1509
2013
  const rec = state.steps["asc-key"];
@@ -1513,7 +2017,7 @@ async function readStateCreds() {
1513
2017
  const keyId = out.keyId;
1514
2018
  const p8Path = out.p8Path;
1515
2019
  if (!issuerId || !keyId || !p8Path) return null;
1516
- if (!await fileExists3(p8Path)) return null;
2020
+ if (!await fileExists2(p8Path)) return null;
1517
2021
  return { issuerId, keyId, privateKey: { path: p8Path } };
1518
2022
  }
1519
2023
  async function runAscKey(options2) {
@@ -1525,25 +2029,12 @@ async function runAscKey(options2) {
1525
2029
  bad("no cached ASC key in state.json; run without --revalidate first");
1526
2030
  return 1;
1527
2031
  }
1528
- const easSays = await easAscStatus();
1529
- if (easSays === "connected") {
1530
- ok("cached key valid (verified via `eas integrations:asc:status`)");
1531
- await recordStep("asc-key", {
1532
- issuerId: cached2.issuerId,
1533
- keyId: cached2.keyId,
1534
- p8Path: "path" in cached2.privateKey ? cached2.privateKey.path : void 0
1535
- });
1536
- return 0;
1537
- }
1538
2032
  const result = await validate(cached2);
1539
2033
  if (!result.ok) {
1540
2034
  bad(`cached key invalid: ${result.reason}`);
1541
2035
  return 1;
1542
2036
  }
1543
2037
  ok(`cached key still valid (${result.appCount} app${result.appCount === 1 ? "" : "s"})`);
1544
- if (easSays === "disconnected") {
1545
- note("EAS reports no ASC link; run `vexpo asc connect` to wire it up");
1546
- }
1547
2038
  await recordStep("asc-key", {
1548
2039
  issuerId: cached2.issuerId,
1549
2040
  keyId: cached2.keyId,
@@ -1592,7 +2083,7 @@ async function runAscKey(options2) {
1592
2083
  note(
1593
2084
  `${BOLD}This step is purely validation${RESET} ${DIM}- EAS still needs the same key uploaded:${RESET}`
1594
2085
  );
1595
- note(` ${BOLD}bunx eas credentials -p ios${RESET}`);
2086
+ note(` ${BOLD}npx eas credentials -p ios${RESET}`);
1596
2087
  note(` \u2192 Build Credentials \u2192 'Use existing App Store Connect API Key'`);
1597
2088
  note(` \u2192 'Set up a new key' if no existing match, paste:`);
1598
2089
  note(` issuer=${creds.issuerId}, keyId=${creds.keyId}`);
@@ -1611,7 +2102,7 @@ async function runEasRotationSecrets(options2) {
1611
2102
  existing = await envList("production");
1612
2103
  } catch (err) {
1613
2104
  bad(`could not list EAS production env: ${err instanceof Error ? err.message : err}`);
1614
- note("run `bunx eas login` and `bunx eas init` first");
2105
+ note("run `npx eas login` and `npx eas init` first");
1615
2106
  return 1;
1616
2107
  }
1617
2108
  const teamId = await readOne("APPLE_TEAM_ID");
@@ -1635,15 +2126,14 @@ async function runEasRotationSecrets(options2) {
1635
2126
  note("re-run with APPLE_P8_PATH=/path/to/AuthKey.p8");
1636
2127
  return 1;
1637
2128
  }
1638
- let pem;
1639
2129
  try {
1640
- pem = await readFile(p8Path, "utf8");
2130
+ await access(p8Path);
1641
2131
  } catch {
1642
2132
  bad(`.p8 file not found at ${p8Path}`);
1643
2133
  return 1;
1644
2134
  }
1645
2135
  const apple2 = [
1646
- { name: "APPLE_P8_PRIVATE_KEY", value: pem, type: "file" },
2136
+ { name: "APPLE_P8_PRIVATE_KEY", value: p8Path, type: "file" },
1647
2137
  { name: "APPLE_TEAM_ID", value: teamId },
1648
2138
  { name: "APPLE_KEY_ID", value: keyId },
1649
2139
  { name: "APPLE_SERVICES_ID", value: servicesId }
@@ -1675,98 +2165,59 @@ async function runEasRotationSecrets(options2) {
1675
2165
  }
1676
2166
  if (!existing.has("CONVEX_DEPLOY_KEY") || options2.force) {
1677
2167
  line();
1678
- note("CONVEX_DEPLOY_KEY isn't set on EAS production. Generate one:");
1679
- await helpAndWait({
1680
- body: "Open the Convex dashboard for your project:",
1681
- urls: [
1682
- {
1683
- label: "Convex dashboard (Settings \u2192 Deploy keys)",
1684
- url: "https://dashboard.convex.dev"
1685
- }
1686
- ],
1687
- allowSkip: true,
1688
- skipLabel: "skip"
1689
- });
1690
- if (process.stdin.isTTY) {
1691
- const key = (await ask(` Paste Convex prod deploy key ${DIM}(or Enter to skip)${RESET} > `)).trim();
1692
- if (key) {
1693
- try {
1694
- if (existing.has("CONVEX_DEPLOY_KEY")) {
1695
- await envUpdate("CONVEX_DEPLOY_KEY", key, "secret", ENVS);
1696
- updated += 1;
1697
- } else {
1698
- await envCreate("CONVEX_DEPLOY_KEY", key, "secret", ENVS);
1699
- applied += 1;
1700
- }
1701
- ok("CONVEX_DEPLOY_KEY set");
1702
- } catch (err) {
1703
- bad(`CONVEX_DEPLOY_KEY: ${err instanceof Error ? err.message : err}`);
1704
- return 1;
1705
- }
2168
+ const setKey = async (key) => {
2169
+ if (existing.has("CONVEX_DEPLOY_KEY")) {
2170
+ await envUpdate("CONVEX_DEPLOY_KEY", key, "secret", ENVS);
2171
+ updated += 1;
1706
2172
  } else {
1707
- yep("skipped CONVEX_DEPLOY_KEY (set later with `eas env:create`)");
1708
- skipped2 += 1;
2173
+ await envCreate("CONVEX_DEPLOY_KEY", key, "secret", ENVS);
2174
+ applied += 1;
1709
2175
  }
1710
- } else {
1711
- yep("skipped CONVEX_DEPLOY_KEY (non-interactive)");
1712
- skipped2 += 1;
2176
+ };
2177
+ let minted = false;
2178
+ try {
2179
+ const result = await mintProdDeployKey(
2180
+ deploymentSlug(await readOne("CONVEX_DEPLOYMENT")) ?? "",
2181
+ "eas-rotation"
2182
+ );
2183
+ if (result) {
2184
+ await setKey(result.key);
2185
+ ok(`minted + set CONVEX_DEPLOY_KEY for prod ${BOLD}${result.deployment}${RESET}`);
2186
+ minted = true;
2187
+ } else {
2188
+ yep("couldn't resolve the prod deployment (offline or not logged in)");
2189
+ }
2190
+ } catch (err) {
2191
+ yep(`couldn't mint a deploy key: ${err instanceof Error ? err.message : err}`);
1713
2192
  }
1714
- } else {
1715
- nop("CONVEX_DEPLOY_KEY already set");
1716
- skipped2 += 1;
1717
- }
1718
- line();
1719
- ok(`${applied} created, ${updated} updated, ${skipped2} skipped (of ${apple2.length + 1} secrets)`);
1720
- yep(`${BOLD}rotation cron${RESET} reads these on the next fire (every 3 months on the 1st)`);
1721
- return 0;
1722
- }
1723
- async function readJsonOrNull(path) {
1724
- try {
1725
- return JSON.parse(await readFile(path, "utf8"));
1726
- } catch {
1727
- return null;
1728
- }
1729
- }
1730
- async function readTextOrNull(path) {
1731
- try {
1732
- await access(path);
1733
- return await readFile(path, "utf8");
1734
- } catch {
1735
- return null;
1736
- }
1737
- }
1738
- async function pkgName() {
1739
- const pkg = await readJsonOrNull("package.json");
1740
- return typeof pkg?.name === "string" && pkg.name ? pkg.name : "app";
1741
- }
1742
- async function appName() {
1743
- const text = await readTextOrNull("app.config.ts");
1744
- if (text) {
1745
- const match = /\bname:\s*["']([^"']+)["']/.exec(text);
1746
- if (match) return match[1];
2193
+ if (!minted) {
2194
+ if (process.stdin.isTTY) {
2195
+ const key = (await ask(` Paste a Convex prod deploy key ${DIM}(or Enter to skip)${RESET} > `)).trim();
2196
+ if (key) {
2197
+ try {
2198
+ await setKey(key);
2199
+ ok("CONVEX_DEPLOY_KEY set");
2200
+ } catch (err) {
2201
+ bad(`CONVEX_DEPLOY_KEY: ${err instanceof Error ? err.message : err}`);
2202
+ return 1;
2203
+ }
2204
+ } else {
2205
+ yep("skipped CONVEX_DEPLOY_KEY (set later with `eas env:create`)");
2206
+ skipped2 += 1;
2207
+ }
2208
+ } else {
2209
+ yep("skipped CONVEX_DEPLOY_KEY (non-interactive, mint unavailable)");
2210
+ skipped2 += 1;
2211
+ }
2212
+ }
2213
+ } else {
2214
+ nop("CONVEX_DEPLOY_KEY already set");
2215
+ skipped2 += 1;
1747
2216
  }
1748
- const name = await pkgName();
1749
- const clean = name.replace(/^@[^/]+\//, "");
1750
- const parts = clean.split(/[-_]/).filter(Boolean);
1751
- if (parts.length === 0) return "App";
1752
- return parts.map((w) => (w[0] ?? "").toUpperCase() + w.slice(1)).join(" ");
1753
- }
1754
- async function scheme() {
1755
- const text = await readTextOrNull("app.config.ts");
1756
- if (!text) return "app";
1757
- return /scheme:\s*["']([^"']+)["']/.exec(text)?.[1] ?? "app";
1758
- }
1759
- async function bundleIdFallback() {
1760
- const text = await readTextOrNull("app.config.ts");
1761
- if (!text) return null;
1762
- return /EXPO_PUBLIC_APP_BUNDLE_ID\s*\?\?\s*"([^"]+)"/.exec(text)?.[1] ?? null;
1763
- }
1764
- async function appleTeamIdFallback() {
1765
- const text = await readTextOrNull("app.config.ts");
1766
- if (!text) return null;
1767
- const value = /EXPO_PUBLIC_APPLE_TEAM_ID\s*\?\?\s*"([^"]+)"/.exec(text)?.[1] ?? null;
1768
- if (!value || value === "ABCDE12345") return null;
1769
- return value;
2217
+ line();
2218
+ ok(`${applied} created, ${updated} updated, ${skipped2} skipped (of ${apple2.length + 1} secrets)`);
2219
+ yep(`${BOLD}rotation cron${RESET} reads these on the next fire (every 3 months on the 1st)`);
2220
+ return 0;
1770
2221
  }
1771
2222
 
1772
2223
  // src/commands/apple/services-id.ts
@@ -1787,23 +2238,23 @@ async function loadAscCreds() {
1787
2238
  if (!sIssuer || !sKey || !sPath) return null;
1788
2239
  return { issuerId: sIssuer, keyId: sKey, privateKey: { path: sPath } };
1789
2240
  }
1790
- async function findOrCreateBundleId(client2, args) {
1791
- const all = await client2.bundleIds.list({ identifier: args.identifier });
2241
+ async function findOrCreateBundleId(client, args) {
2242
+ const all = await client.bundleIds.list({ identifier: args.identifier });
1792
2243
  const existing = all.find((b) => {
1793
2244
  if (b.attributes.identifier !== args.identifier) return false;
1794
2245
  if (args.platform === "SERVICES") return b.attributes.platform === "SERVICES";
1795
2246
  return b.attributes.platform !== "SERVICES";
1796
2247
  });
1797
2248
  if (existing) return existing;
1798
- return client2.bundleIds.create({
2249
+ return client.bundleIds.create({
1799
2250
  identifier: args.identifier,
1800
2251
  name: args.name,
1801
2252
  platform: args.platform
1802
2253
  });
1803
2254
  }
1804
- async function findServicesIdOrPromptManual(client2, identifier) {
2255
+ async function findServicesIdOrPromptManual(client, identifier) {
1805
2256
  const lookup = async () => {
1806
- const matches = await client2.bundleIds.list({ identifier });
2257
+ const matches = await client.bundleIds.list({ identifier });
1807
2258
  return matches.find((b) => b.attributes.identifier === identifier) ?? null;
1808
2259
  };
1809
2260
  const found = await lookup();
@@ -1862,26 +2313,26 @@ async function runServicesId(options2) {
1862
2313
  ok(
1863
2314
  `ASC API authenticated (${validation.appCount} app${validation.appCount === 1 ? "" : "s"} on team)`
1864
2315
  );
1865
- const client2 = makeAscClient(creds);
2316
+ const client = makeAscClient(creds);
1866
2317
  const servicesId = options2.servicesId ?? process.env.APPLE_SERVICES_ID ?? `${bundleId}.signin`;
1867
2318
  const name = await appName();
1868
- const appBundle = await findOrCreateBundleId(client2, {
2319
+ const appBundle = await findOrCreateBundleId(client, {
1869
2320
  identifier: bundleId,
1870
2321
  name,
1871
2322
  platform: "IOS"
1872
2323
  });
1873
2324
  ok(`app bundle id resource: ${appBundle.id} (${appBundle.attributes.identifier})`);
1874
- const sid = await findServicesIdOrPromptManual(client2, servicesId);
2325
+ const sid = await findServicesIdOrPromptManual(client, servicesId);
1875
2326
  if (!sid) return 1;
1876
2327
  ok(`services id resource: ${sid.id} (${servicesId})`);
1877
- const caps = await client2.bundleIdCapabilities.list(appBundle.id);
2328
+ const caps = await client.bundleIdCapabilities.list(appBundle.id);
1878
2329
  const siwaCap = caps.find((c) => c.attributes.capabilityType === SIGN_IN_WITH_APPLE_CAPABILITY);
1879
2330
  let siwaCapId;
1880
2331
  if (siwaCap) {
1881
2332
  siwaCapId = siwaCap.id;
1882
2333
  nop("Sign In with Apple capability already enabled on app bundle id");
1883
2334
  } else {
1884
- const created = await client2.bundleIdCapabilities.create({
2335
+ const created = await client.bundleIdCapabilities.create({
1885
2336
  bundleIdResourceId: appBundle.id,
1886
2337
  capabilityType: SIGN_IN_WITH_APPLE_CAPABILITY
1887
2338
  });
@@ -1905,6 +2356,144 @@ async function runServicesId(options2) {
1905
2356
  return 1;
1906
2357
  }
1907
2358
  }
2359
+
2360
+ // src/lib/eas-submit.ts
2361
+ function isObject(value) {
2362
+ return typeof value === "object" && value !== null && !Array.isArray(value);
2363
+ }
2364
+ function submitProfilesMissingAscAppId(easJson) {
2365
+ let cfg;
2366
+ try {
2367
+ cfg = JSON.parse(easJson);
2368
+ } catch {
2369
+ return [];
2370
+ }
2371
+ return Object.entries(cfg.submit ?? {}).filter(([, p]) => isObject(p?.ios) && !p.ios.ascAppId).map(([name]) => name);
2372
+ }
2373
+ function needsAscAppId(easJson, ascAppId) {
2374
+ let cfg;
2375
+ try {
2376
+ cfg = JSON.parse(easJson);
2377
+ } catch {
2378
+ return false;
2379
+ }
2380
+ return Object.values(cfg.submit ?? {}).some(
2381
+ (p) => isObject(p?.ios) && p.ios.ascAppId !== ascAppId
2382
+ );
2383
+ }
2384
+ function withAscAppId(easJson, ascAppId) {
2385
+ if (!needsAscAppId(easJson, ascAppId)) return easJson;
2386
+ const cfg = JSON.parse(easJson);
2387
+ for (const profile of Object.values(cfg.submit ?? {})) {
2388
+ if (isObject(profile?.ios)) profile.ios.ascAppId = ascAppId;
2389
+ }
2390
+ return JSON.stringify(cfg, null, 2) + "\n";
2391
+ }
2392
+
2393
+ // src/commands/asc.ts
2394
+ async function loadAscFromState2() {
2395
+ const state = await load();
2396
+ const rec = state.steps["asc-key"];
2397
+ if (!rec?.outputs) return null;
2398
+ const out = rec.outputs;
2399
+ const issuerId = out.issuerId;
2400
+ const keyId = out.keyId;
2401
+ const rawPath = out.p8Path;
2402
+ if (!issuerId || !keyId || !rawPath) return null;
2403
+ const p8Path = expandTilde(rawPath);
2404
+ if (!existsSync(p8Path)) return null;
2405
+ return { issuerId, keyId, p8Path };
2406
+ }
2407
+ async function runAscConnect(opts = {}) {
2408
+ section("ASC connect");
2409
+ if (!opts.force) {
2410
+ try {
2411
+ const status = await ascStatus();
2412
+ if (status.status === "connected" && status.appStoreConnectApp) {
2413
+ const label = status.appStoreConnectApp.bundleIdentifier ?? status.appStoreConnectApp.ascAppIdentifier;
2414
+ nop(`already connected (${label})`);
2415
+ await recordStep("apple-asc-link", {
2416
+ ascAppId: status.appStoreConnectApp.ascAppIdentifier,
2417
+ ascAppEasId: status.appStoreConnectApp.id,
2418
+ bundleId: status.appStoreConnectApp.bundleIdentifier,
2419
+ connectedAt: (/* @__PURE__ */ new Date()).toISOString()
2420
+ });
2421
+ return 0;
2422
+ }
2423
+ } catch {
2424
+ }
2425
+ }
2426
+ const asc = await loadAscFromState2();
2427
+ if (!asc) {
2428
+ bad("no cached ASC creds. Run `vexpo apple asc-key` first to validate one.");
2429
+ return 1;
2430
+ }
2431
+ ok("cached ASC API key found in state.json");
2432
+ note(` issuerId: ${BOLD}${asc.issuerId}${RESET}`);
2433
+ note(` keyId: ${BOLD}${asc.keyId}${RESET}`);
2434
+ note(` .p8: ${BOLD}${asc.p8Path}${RESET}`);
2435
+ const bundleId = await readOne("EXPO_PUBLIC_APP_BUNDLE_ID");
2436
+ if (!bundleId) {
2437
+ bad("no EXPO_PUBLIC_APP_BUNDLE_ID in .env.local. Run `vexpo convex` first.");
2438
+ return 1;
2439
+ }
2440
+ ok(`bundle id: ${BOLD}${bundleId}${RESET}`);
2441
+ if (!process.stdin.isTTY) {
2442
+ bad("ASC connect needs a TTY: eas integrations:asc:connect can't run headless");
2443
+ note("run `vexpo asc:connect` in an interactive terminal to finish the EAS\u2194ASC link");
2444
+ return 1;
2445
+ }
2446
+ const env2 = {
2447
+ ...process.env,
2448
+ EXPO_ASC_API_KEY_PATH: asc.p8Path,
2449
+ EXPO_ASC_KEY_ID: asc.keyId,
2450
+ EXPO_ASC_ISSUER_ID: asc.issuerId
2451
+ };
2452
+ line();
2453
+ note("spawning `eas integrations:asc:connect`. Most likely flow:");
2454
+ note(" 1. Press Y to generate a new ASC API key (default)");
2455
+ note(" 2. Press Enter to accept ADMIN role (default)");
2456
+ note("EXPO_ASC_API_KEY_* env vars are set so eas-cli uses our cached key");
2457
+ note("for the Apple auth step, no Apple ID + password prompt.");
2458
+ const proc = spawn([dlx(), "eas", "integrations:asc:connect", "--bundle-id", bundleId], {
2459
+ stdin: "inherit",
2460
+ stdout: "inherit",
2461
+ stderr: "inherit",
2462
+ env: env2
2463
+ });
2464
+ const code = await proc.exited;
2465
+ if (code !== 0) {
2466
+ bad(`eas integrations:asc:connect exited with code ${code}`);
2467
+ return code;
2468
+ }
2469
+ ok("EAS project linked to ASC app");
2470
+ await recordStep("apple-asc-link", {
2471
+ bundleId,
2472
+ ascIssuerId: asc.issuerId,
2473
+ ascKeyId: asc.keyId,
2474
+ connectedAt: (/* @__PURE__ */ new Date()).toISOString()
2475
+ });
2476
+ if (existsSync("eas.json")) {
2477
+ try {
2478
+ const ascAppId = (await ascStatus()).appStoreConnectApp?.ascAppIdentifier;
2479
+ if (ascAppId) {
2480
+ const before = await readFile("eas.json", "utf8");
2481
+ const after = withAscAppId(before, ascAppId);
2482
+ if (after !== before) {
2483
+ await writeFile("eas.json", after);
2484
+ ok(`wrote ascAppId ${BOLD}${ascAppId}${RESET} to eas.json submit profiles`);
2485
+ note("commit this in your fork: non-interactive `eas submit` (CI) needs it");
2486
+ } else {
2487
+ nop("eas.json submit profiles already carry ascAppId");
2488
+ }
2489
+ }
2490
+ } catch (err) {
2491
+ yep(`couldn't write ascAppId to eas.json: ${err instanceof Error ? err.message : err}`);
2492
+ note("non-interactive submit will need `ascAppId` set manually in eas.json");
2493
+ }
2494
+ }
2495
+ return 0;
2496
+ }
1908
2497
  async function loadAscCreds2() {
1909
2498
  const state = await load();
1910
2499
  const rec = state.steps["asc-key"];
@@ -1923,459 +2512,330 @@ async function ascBootstrap() {
1923
2512
  if (!creds) {
1924
2513
  throw new Error("no cached ASC creds. run `vexpo apple asc-key` first");
1925
2514
  }
1926
- const client2 = makeAscClient(creds);
2515
+ const client = makeAscClient(creds);
1927
2516
  const bundleId = await readOne("EXPO_PUBLIC_APP_BUNDLE_ID") ?? await readOne("APP_BUNDLE_ID") ?? void 0;
1928
2517
  let ascAppId;
1929
2518
  if (bundleId) {
1930
2519
  try {
1931
- const apps = await client2.paginatedList("/v1/apps", { "filter[bundleId]": bundleId }, 5);
2520
+ const apps = await client.paginatedList("/v1/apps", { "filter[bundleId]": bundleId }, 5);
1932
2521
  ascAppId = apps[0]?.id;
1933
2522
  } catch {
1934
2523
  }
1935
2524
  }
1936
- return { client: client2, bundleId, ascAppId, creds };
1937
- }
1938
-
1939
- // src/lib/asc-versions.ts
1940
- function versions(client2) {
1941
- return {
1942
- /* app store versions ------------------------------------------------ */
1943
- appStoreVersions: {
1944
- list(filter) {
1945
- const query = {};
1946
- if (filter?.platform) query["filter[platform]"] = filter.platform;
1947
- if (filter?.versionString) query["filter[versionString]"] = filter.versionString;
1948
- if (filter?.state) query["filter[appStoreState]"] = filter.state;
1949
- const path = filter?.appId ? `/v1/apps/${filter.appId}/appStoreVersions` : "/v1/appStoreVersions";
1950
- return client2.paginatedList(path, query, filter?.limit ?? 25);
1951
- },
1952
- async get(id) {
1953
- const res = await client2.request(
1954
- "GET",
1955
- `/v1/appStoreVersions/${id}`
1956
- );
1957
- return res.data;
1958
- },
1959
- async getBuild(versionId) {
1960
- try {
1961
- return await client2.request(
1962
- "GET",
1963
- `/v1/appStoreVersions/${versionId}/relationships/build`
1964
- );
1965
- } catch {
1966
- return null;
1967
- }
1968
- }
1969
- },
1970
- /* review submissions ------------------------------------------------ */
1971
- reviewSubmissions: {
1972
- list(filter) {
1973
- const query = {};
1974
- if (filter?.appId) query["filter[app]"] = filter.appId;
1975
- if (filter?.platform) query["filter[platform]"] = filter.platform;
1976
- if (filter?.state) query["filter[state]"] = filter.state;
1977
- return client2.paginatedList("/v1/reviewSubmissions", query);
1978
- },
1979
- async get(id) {
1980
- const res = await client2.request(
1981
- "GET",
1982
- `/v1/reviewSubmissions/${id}`
1983
- );
1984
- return res.data;
1985
- }
1986
- },
1987
- /* phased release ---------------------------------------------------- */
1988
- phasedReleases: {
1989
- async getForVersion(versionId) {
1990
- try {
1991
- const res = await client2.request(
1992
- "GET",
1993
- `/v1/appStoreVersions/${versionId}/appStoreVersionPhasedRelease`
1994
- );
1995
- return res.data;
1996
- } catch {
1997
- return null;
1998
- }
1999
- },
2000
- async pause(id) {
2001
- const body = {
2002
- data: {
2003
- type: "appStoreVersionPhasedReleases",
2004
- id,
2005
- attributes: { phasedReleaseState: "PAUSED" }
2006
- }
2007
- };
2008
- const res = await client2.request(
2009
- "PATCH",
2010
- `/v1/appStoreVersionPhasedReleases/${id}`,
2011
- body
2012
- );
2013
- return res.data;
2014
- },
2015
- async resume(id) {
2016
- const body = {
2017
- data: {
2018
- type: "appStoreVersionPhasedReleases",
2019
- id,
2020
- attributes: { phasedReleaseState: "ACTIVE" }
2021
- }
2022
- };
2023
- const res = await client2.request(
2024
- "PATCH",
2025
- `/v1/appStoreVersionPhasedReleases/${id}`,
2026
- body
2027
- );
2028
- return res.data;
2029
- },
2030
- async complete(id) {
2031
- const body = {
2032
- data: {
2033
- type: "appStoreVersionPhasedReleases",
2034
- id,
2035
- attributes: { phasedReleaseState: "COMPLETE" }
2036
- }
2037
- };
2038
- const res = await client2.request(
2039
- "PATCH",
2040
- `/v1/appStoreVersionPhasedReleases/${id}`,
2041
- body
2042
- );
2043
- return res.data;
2044
- }
2045
- }
2046
- };
2525
+ return { client, bundleId, ascAppId, creds };
2047
2526
  }
2048
2527
 
2049
- // src/commands/asc-version.ts
2050
- async function bootstrap() {
2051
- const { client: client2, ascAppId, bundleId } = await ascBootstrap();
2052
- if (!ascAppId) {
2053
- throw new Error(
2054
- `no ASC app for bundle id ${bundleId ?? "(unset)"}; run \`vexpo apple credentials\` first`
2055
- );
2056
- }
2057
- return { v: versions(client2), ascAppId };
2058
- }
2059
- async function runVersionList(opts) {
2060
- try {
2061
- const { v, ascAppId } = await bootstrap();
2062
- const list = await v.appStoreVersions.list({
2063
- appId: ascAppId,
2064
- platform: opts.platform,
2065
- state: opts.state,
2066
- limit: opts.limit
2067
- });
2068
- if (opts.json) {
2069
- process.stdout.write(JSON.stringify(list, null, 2) + "\n");
2070
- return 0;
2071
- }
2072
- section("App Store versions");
2073
- if (list.length === 0) {
2074
- nop("none");
2075
- return 0;
2076
- }
2077
- for (const ver of list) {
2078
- line(
2079
- ` ${BOLD}${ver.attributes.versionString ?? "?"}${RESET} ${ver.attributes.platform ?? "?"} ${DIM}${ver.attributes.appStoreState ?? "?"}${RESET}`
2080
- );
2081
- }
2082
- return 0;
2083
- } catch (err) {
2084
- bad(err instanceof Error ? err.message : String(err));
2085
- return 1;
2086
- }
2087
- }
2088
- async function runVersionView(versionId, opts) {
2089
- try {
2090
- const { v } = await bootstrap();
2091
- const ver = await v.appStoreVersions.get(versionId);
2092
- const phased = await v.phasedReleases.getForVersion(versionId).catch(() => null);
2093
- if (opts.json) {
2094
- process.stdout.write(JSON.stringify({ version: ver, phasedRelease: phased }, null, 2) + "\n");
2095
- return 0;
2096
- }
2097
- section(`Version ${ver.attributes.versionString ?? versionId}`);
2098
- line(` state: ${ver.attributes.appStoreState ?? "?"}`);
2099
- line(` platform: ${ver.attributes.platform ?? "?"}`);
2100
- if (ver.attributes.releaseType) line(` release: ${ver.attributes.releaseType}`);
2101
- if (ver.attributes.earliestReleaseDate)
2102
- line(` earliest: ${ver.attributes.earliestReleaseDate}`);
2103
- if (phased) {
2104
- line(` phased: ${phased.attributes.phasedReleaseState ?? "?"}`);
2105
- if (phased.attributes.startDate) line(` started ${phased.attributes.startDate}`);
2106
- }
2107
- return 0;
2108
- } catch (err) {
2109
- bad(err instanceof Error ? err.message : String(err));
2110
- return 1;
2111
- }
2112
- }
2113
- async function runSubmissionsList(opts) {
2114
- try {
2115
- const { v, ascAppId } = await bootstrap();
2116
- const submissions = await v.reviewSubmissions.list({
2117
- appId: ascAppId,
2118
- platform: opts.platform,
2119
- state: opts.state
2528
+ // src/lib/asc-accessibility.ts
2529
+ var ACCESSIBILITY_FEATURES = [
2530
+ "VOICE_OVER",
2531
+ "VOICE_CONTROL",
2532
+ "LARGER_TEXT",
2533
+ "DARK_INTERFACE",
2534
+ "SUFFICIENT_CONTRAST",
2535
+ "DIFFERENTIATION_WITHOUT_COLOR_ALONE",
2536
+ "REDUCED_MOTION",
2537
+ "CAPTIONS",
2538
+ "AUDIO_DESCRIPTIONS"
2539
+ ];
2540
+ var ACCESSIBILITY_LEVELS = [
2541
+ "FULLY_SUPPORTS",
2542
+ "PARTIAL",
2543
+ "DOES_NOT_SUPPORT",
2544
+ "NOT_APPLICABLE"
2545
+ ];
2546
+ var ACCESSIBILITY_DEVICE_FAMILIES = [
2547
+ "IPHONE",
2548
+ "IPAD",
2549
+ "MAC",
2550
+ "APPLE_TV",
2551
+ "APPLE_WATCH",
2552
+ "VISION"
2553
+ ];
2554
+ function lintAccessibilityConfig(config) {
2555
+ const issues = [];
2556
+ if (!isRecord(config)) {
2557
+ issues.push({ severity: "error", message: "config must be a JSON object" });
2558
+ return issues;
2559
+ }
2560
+ if (!Array.isArray(config.entries)) {
2561
+ issues.push({ severity: "error", message: "`entries` must be an array" });
2562
+ return issues;
2563
+ }
2564
+ if (config.entries.length === 0) {
2565
+ issues.push({
2566
+ severity: "warning",
2567
+ message: "`entries` is empty; declare at least one device family."
2120
2568
  });
2121
- if (opts.json) {
2122
- process.stdout.write(JSON.stringify(submissions, null, 2) + "\n");
2123
- return 0;
2124
- }
2125
- section("Review submissions");
2126
- if (submissions.length === 0) {
2127
- nop("none");
2128
- return 0;
2129
- }
2130
- for (const s of submissions) {
2131
- line(
2132
- ` ${BOLD}${s.id.slice(0, 8)}${RESET} ${s.attributes.state ?? "?"} ${DIM}${s.attributes.submittedDate ?? ""}${RESET}`
2133
- );
2134
- }
2135
- return 0;
2136
- } catch (err) {
2137
- bad(err instanceof Error ? err.message : String(err));
2138
- return 1;
2139
- }
2140
- }
2141
- async function runPhasedRelease(opts) {
2142
- try {
2143
- const { v } = await bootstrap();
2144
- const phased = await v.phasedReleases.getForVersion(opts.versionId);
2145
- if (!phased) {
2146
- bad(`no phased release for version ${opts.versionId}`);
2147
- return 1;
2148
- }
2149
- const next = opts.action === "pause" ? await v.phasedReleases.pause(phased.id) : opts.action === "resume" ? await v.phasedReleases.resume(phased.id) : await v.phasedReleases.complete(phased.id);
2150
- section(`Phased release ${opts.action}d`);
2151
- ok(next.attributes.phasedReleaseState ?? "ok");
2152
- return 0;
2153
- } catch (err) {
2154
- bad(err instanceof Error ? err.message : String(err));
2155
- return 1;
2156
2569
  }
2157
- }
2158
-
2159
- // src/commands/reviews.ts
2160
- async function bootstrap2() {
2161
- const { client: client2, ascAppId, bundleId } = await ascBootstrap();
2162
- if (!ascAppId) {
2163
- throw new Error(
2164
- `no ASC app for bundle id ${bundleId ?? "(unset)"}; run \`vexpo apple credentials\` first`
2165
- );
2166
- }
2167
- return { r: reviews(client2), ascAppId };
2168
- }
2169
- function stars(n) {
2170
- if (!n) return "";
2171
- const filled = "\u2605".repeat(n);
2172
- const empty2 = "\u2606".repeat(5 - n);
2173
- return `${filled}${empty2}`;
2174
- }
2175
- async function runReviewsList(opts) {
2176
- try {
2177
- const { r, ascAppId } = await bootstrap2();
2178
- const list = await r.customerReviews.list({
2179
- appId: ascAppId,
2180
- territory: opts.territory,
2181
- rating: opts.rating,
2182
- limit: opts.limit
2183
- });
2184
- if (opts.json) {
2185
- process.stdout.write(JSON.stringify(list, null, 2) + "\n");
2186
- return 0;
2570
+ const seen = /* @__PURE__ */ new Set();
2571
+ config.entries.forEach((raw, index) => {
2572
+ if (!isRecord(raw)) {
2573
+ issues.push({ severity: "error", message: `entry[${index}] must be an object` });
2574
+ return;
2575
+ }
2576
+ const family = raw.deviceFamily;
2577
+ if (typeof family !== "string" || !ACCESSIBILITY_DEVICE_FAMILIES.includes(family)) {
2578
+ issues.push({
2579
+ severity: "error",
2580
+ message: `entry[${index}].deviceFamily '${String(family)}' is not a valid AccessibilityDeviceFamily. Allowed: ${ACCESSIBILITY_DEVICE_FAMILIES.join(", ")}`
2581
+ });
2582
+ } else if (seen.has(family)) {
2583
+ issues.push({
2584
+ severity: "warning",
2585
+ message: `entry[${index}].deviceFamily '${family}' is duplicated; only the last entry counts.`
2586
+ });
2587
+ } else {
2588
+ seen.add(family);
2187
2589
  }
2188
- section("Customer reviews");
2189
- if (list.length === 0) {
2190
- nop("none");
2191
- return 0;
2590
+ if (!isRecord(raw.features)) {
2591
+ issues.push({ severity: "error", message: `entry[${index}].features must be an object` });
2592
+ return;
2192
2593
  }
2193
- for (const rev of list) {
2194
- const responded = rev.relationships?.response?.data ? "\u2713" : " ";
2195
- const created = rev.attributes.createdDate?.slice(0, 10) ?? "";
2196
- line(
2197
- ` ${responded} ${stars(rev.attributes.rating)} ${DIM}${created} ${rev.attributes.territory ?? ""}${RESET} ${BOLD}${rev.attributes.title ?? ""}${RESET}`
2198
- );
2199
- if (rev.attributes.body) {
2200
- const truncated = rev.attributes.body.split("\n")[0];
2201
- line(
2202
- ` ${DIM}${truncated.slice(0, 100)}${truncated.length > 100 ? "\u2026" : ""}${RESET}`
2203
- );
2594
+ for (const [feature, level] of Object.entries(raw.features)) {
2595
+ if (!ACCESSIBILITY_FEATURES.includes(feature)) {
2596
+ issues.push({
2597
+ severity: "error",
2598
+ message: `entry[${index}].features['${feature}'] is not a valid AccessibilityFeature. Allowed: ${ACCESSIBILITY_FEATURES.join(", ")}`
2599
+ });
2600
+ }
2601
+ if (typeof level !== "string" || !ACCESSIBILITY_LEVELS.includes(level)) {
2602
+ issues.push({
2603
+ severity: "error",
2604
+ message: `entry[${index}].features['${feature}'] level '${String(level)}' is not a valid AccessibilityLevel. Allowed: ${ACCESSIBILITY_LEVELS.join(", ")}`
2605
+ });
2204
2606
  }
2205
- line(` ${DIM}id ${rev.id}${RESET}`);
2206
2607
  }
2207
- return 0;
2208
- } catch (err) {
2209
- bad(err instanceof Error ? err.message : String(err));
2210
- return 1;
2211
- }
2608
+ });
2609
+ return issues;
2610
+ }
2611
+ async function fetchAccessibilityDeclarations(client, appId) {
2612
+ return client.request("GET", `/v1/apps/${appId}/accessibilityDeclarations`);
2613
+ }
2614
+ function isRecord(value) {
2615
+ return typeof value === "object" && value !== null && !Array.isArray(value);
2212
2616
  }
2213
- async function runReviewsUnanswered(opts) {
2617
+
2618
+ // src/commands/asc-accessibility.ts
2619
+ async function runAccessibilityShow(opts) {
2214
2620
  try {
2215
- const { r, ascAppId } = await bootstrap2();
2216
- const all = await r.customerReviews.list({ appId: ascAppId, limit: opts.limit ?? 200 });
2217
- const unresponded = unansweredOlderThan(all, opts.days ?? 0);
2218
- if (opts.json) {
2219
- process.stdout.write(JSON.stringify(unresponded, null, 2) + "\n");
2220
- return 0;
2621
+ const { client, ascAppId, bundleId } = await ascBootstrap();
2622
+ if (!ascAppId) {
2623
+ bad(`no ASC app for bundle id ${bundleId ?? "(unset)"}`);
2624
+ return 1;
2221
2625
  }
2222
- section(
2223
- `Unanswered reviews${opts.days ? ` (older than ${opts.days} day${opts.days === 1 ? "" : "s"})` : ""}`
2224
- );
2225
- if (unresponded.length === 0) {
2226
- ok("all caught up");
2626
+ const decls = await fetchAccessibilityDeclarations(client, ascAppId);
2627
+ if (opts.json) {
2628
+ process.stdout.write(JSON.stringify(decls, null, 2) + "\n");
2227
2629
  return 0;
2228
2630
  }
2229
- for (const rev of unresponded) {
2230
- const created = rev.attributes.createdDate?.slice(0, 10) ?? "";
2231
- line(
2232
- ` ${stars(rev.attributes.rating)} ${DIM}${created} ${rev.attributes.territory ?? ""}${RESET} ${BOLD}${rev.attributes.title ?? ""}${RESET}`
2233
- );
2234
- line(` ${DIM}respond: vexpo reviews respond ${rev.id} "..."${RESET}`);
2235
- }
2631
+ section("Accessibility declarations");
2632
+ line(JSON.stringify(decls, null, 2));
2236
2633
  return 0;
2237
2634
  } catch (err) {
2238
2635
  bad(err instanceof Error ? err.message : String(err));
2239
2636
  return 1;
2240
2637
  }
2241
2638
  }
2242
- async function runReviewsRespond(opts) {
2639
+ async function runAccessibilityLint(filePath) {
2640
+ let parsed;
2243
2641
  try {
2244
- const { r } = await bootstrap2();
2245
- const response = await r.customerReviewResponses.create({
2246
- reviewId: opts.reviewId,
2247
- responseBody: opts.body
2248
- });
2249
- section(`Responded to ${opts.reviewId}`);
2250
- ok(`response ${response.id} ${DIM}${response.attributes.state ?? ""}${RESET}`);
2251
- return 0;
2642
+ parsed = JSON.parse(readFileSync(filePath, "utf8"));
2252
2643
  } catch (err) {
2253
- bad(err instanceof Error ? err.message : String(err));
2644
+ bad(`failed to read ${filePath}: ${err instanceof Error ? err.message : String(err)}`);
2254
2645
  return 1;
2255
2646
  }
2256
- }
2257
- async function runReviewsDeleteResponse(responseId) {
2258
- try {
2259
- const { r } = await bootstrap2();
2260
- await r.customerReviewResponses.delete(responseId);
2261
- section(`Deleted response ${responseId}`);
2262
- ok("done");
2647
+ const issues = lintAccessibilityConfig(parsed);
2648
+ const errors = issues.filter((i) => i.severity === "error");
2649
+ const warnings = issues.filter((i) => i.severity === "warning");
2650
+ section(`Accessibility lint: ${filePath}`);
2651
+ for (const i of issues) {
2652
+ const tag = i.severity === "error" ? `${RED}error${RESET}` : `${YELLOW}warn${RESET}`;
2653
+ line(` ${tag} ${i.message}`);
2654
+ }
2655
+ if (errors.length === 0 && warnings.length === 0) {
2656
+ ok("clean");
2263
2657
  return 0;
2264
- } catch (err) {
2265
- bad(err instanceof Error ? err.message : String(err));
2266
- return 1;
2267
2658
  }
2659
+ line(`${BOLD}${errors.length}${RESET} error(s), ${BOLD}${warnings.length}${RESET} warning(s)`);
2660
+ return errors.length > 0 ? 1 : 0;
2268
2661
  }
2269
2662
 
2270
- // src/lib/asc-sandbox.ts
2271
- function sandbox(client2) {
2272
- return {
2273
- sandboxTesters: {
2274
- list() {
2275
- return client2.paginatedList("/v1/sandboxTesters");
2276
- },
2277
- async get(id) {
2278
- const res = await client2.request(
2279
- "GET",
2280
- `/v1/sandboxTesters/${id}`
2281
- );
2282
- return res.data;
2283
- },
2284
- async create(args) {
2285
- const body = {
2286
- data: {
2287
- type: "sandboxTesters",
2288
- attributes: {
2289
- firstName: args.firstName,
2290
- lastName: args.lastName,
2291
- email: args.email,
2292
- password: args.password,
2293
- territory: args.territory
2294
- }
2295
- }
2296
- };
2297
- const res = await client2.request(
2298
- "POST",
2299
- "/v1/sandboxTesters",
2300
- body
2301
- );
2302
- return res.data;
2303
- },
2304
- async delete(id) {
2305
- await client2.request("DELETE", `/v1/sandboxTesters/${id}`);
2306
- }
2663
+ // src/lib/asc-privacy.ts
2664
+ var PRIVACY_DATA_TYPES = [
2665
+ "CONTACT_INFO",
2666
+ "HEALTH_FITNESS",
2667
+ "FINANCIAL_INFO",
2668
+ "LOCATION",
2669
+ "SENSITIVE_INFO",
2670
+ "CONTACTS",
2671
+ "USER_CONTENT",
2672
+ "BROWSING_HISTORY",
2673
+ "SEARCH_HISTORY",
2674
+ "IDENTIFIERS",
2675
+ "PURCHASES",
2676
+ "USAGE_DATA",
2677
+ "DIAGNOSTICS",
2678
+ "OTHER_DATA"
2679
+ ];
2680
+ var PRIVACY_PURPOSES = [
2681
+ "THIRD_PARTY_ADVERTISING",
2682
+ "DEVELOPER_ADVERTISING",
2683
+ "ANALYTICS",
2684
+ "PRODUCT_PERSONALIZATION",
2685
+ "APP_FUNCTIONALITY",
2686
+ "OTHER"
2687
+ ];
2688
+ function lintPrivacyConfig(config) {
2689
+ const issues = [];
2690
+ if (!isRecord2(config)) {
2691
+ issues.push({ severity: "error", message: "config must be a JSON object" });
2692
+ return issues;
2693
+ }
2694
+ if (typeof config.collectsData !== "boolean") {
2695
+ issues.push({ severity: "error", message: "`collectsData` must be a boolean" });
2696
+ }
2697
+ if (!Array.isArray(config.entries)) {
2698
+ issues.push({ severity: "error", message: "`entries` must be an array" });
2699
+ return issues;
2700
+ }
2701
+ if (config.collectsData === false && config.entries.length > 0) {
2702
+ issues.push({
2703
+ severity: "warning",
2704
+ message: "`collectsData` is false but `entries` is non-empty; entries will be ignored."
2705
+ });
2706
+ }
2707
+ if (config.collectsData === true && config.entries.length === 0) {
2708
+ issues.push({
2709
+ severity: "error",
2710
+ message: "`collectsData` is true but `entries` is empty; declare at least one data type."
2711
+ });
2712
+ }
2713
+ const seenCategories = /* @__PURE__ */ new Set();
2714
+ config.entries.forEach((raw, index) => {
2715
+ if (!isRecord2(raw)) {
2716
+ issues.push({ severity: "error", message: `entry[${index}] must be an object` });
2717
+ return;
2718
+ }
2719
+ const category = raw.category;
2720
+ if (typeof category !== "string" || !PRIVACY_DATA_TYPES.includes(category)) {
2721
+ issues.push({
2722
+ severity: "error",
2723
+ message: `entry[${index}].category '${String(category)}' is not a valid PrivacyDataType. Allowed: ${PRIVACY_DATA_TYPES.join(", ")}`
2724
+ });
2725
+ } else if (seenCategories.has(category)) {
2726
+ issues.push({
2727
+ severity: "warning",
2728
+ message: `entry[${index}].category '${category}' is duplicated; only the last entry counts.`
2729
+ });
2730
+ } else {
2731
+ seenCategories.add(category);
2307
2732
  }
2308
- };
2309
- }
2310
-
2311
- // src/commands/sandbox.ts
2312
- async function client() {
2313
- const { client: ascClient } = await ascBootstrap();
2314
- return sandbox(ascClient);
2315
- }
2316
- async function runSandboxList(opts = {}) {
2317
- try {
2318
- const s = await client();
2319
- const list = await s.sandboxTesters.list();
2320
- if (opts.json) {
2321
- process.stdout.write(JSON.stringify(list, null, 2) + "\n");
2322
- return 0;
2733
+ if (typeof raw.collected !== "boolean") {
2734
+ issues.push({ severity: "error", message: `entry[${index}].collected must be a boolean` });
2323
2735
  }
2324
- section("Sandbox testers");
2325
- if (list.length === 0) {
2326
- nop("none");
2327
- return 0;
2736
+ if (typeof raw.usedForTracking !== "boolean") {
2737
+ issues.push({
2738
+ severity: "error",
2739
+ message: `entry[${index}].usedForTracking must be a boolean`
2740
+ });
2328
2741
  }
2329
- for (const t of list) {
2330
- const name = `${t.attributes.firstName ?? ""} ${t.attributes.lastName ?? ""}`.trim();
2331
- line(
2332
- ` ${BOLD}${t.attributes.email ?? "(no email)"}${RESET} ${DIM}${t.attributes.territory ?? ""} ${name}${RESET}`
2333
- );
2742
+ if (typeof raw.linkedToUser !== "boolean") {
2743
+ issues.push({
2744
+ severity: "error",
2745
+ message: `entry[${index}].linkedToUser must be a boolean`
2746
+ });
2334
2747
  }
2748
+ if (!Array.isArray(raw.purposes)) {
2749
+ issues.push({ severity: "error", message: `entry[${index}].purposes must be an array` });
2750
+ } else {
2751
+ raw.purposes.forEach((purpose, j) => {
2752
+ if (typeof purpose !== "string" || !PRIVACY_PURPOSES.includes(purpose)) {
2753
+ issues.push({
2754
+ severity: "error",
2755
+ message: `entry[${index}].purposes[${j}] '${String(purpose)}' is not a valid PrivacyPurpose. Allowed: ${PRIVACY_PURPOSES.join(", ")}`
2756
+ });
2757
+ }
2758
+ });
2759
+ }
2760
+ });
2761
+ return issues;
2762
+ }
2763
+ function isRecord2(value) {
2764
+ return typeof value === "object" && value !== null && !Array.isArray(value);
2765
+ }
2766
+
2767
+ // src/commands/asc-privacy.ts
2768
+ var ASC_PRIVACY_URL = "https://appstoreconnect.apple.com";
2769
+ async function runPrivacyShow(file, opts = {}) {
2770
+ if (!existsSync(file)) {
2771
+ section("Privacy details");
2772
+ note(`no local ${file}. Apple's API can't read the live label; set it in App Store Connect:`);
2773
+ note(` ${ASC_PRIVACY_URL} -> your app -> App Privacy`);
2335
2774
  return 0;
2336
- } catch (err) {
2337
- bad(err instanceof Error ? err.message : String(err));
2338
- return 1;
2339
2775
  }
2340
- }
2341
- async function runSandboxCreate(opts) {
2776
+ let parsed;
2342
2777
  try {
2343
- const s = await client();
2344
- const created = await s.sandboxTesters.create(opts);
2345
- section(`Sandbox tester ${opts.email}`);
2346
- ok(`id ${created.id}`);
2347
- return 0;
2778
+ parsed = JSON.parse(readFileSync(file, "utf8"));
2348
2779
  } catch (err) {
2349
- bad(err instanceof Error ? err.message : String(err));
2780
+ bad(`failed to read ${file}: ${err instanceof Error ? err.message : String(err)}`);
2350
2781
  return 1;
2351
2782
  }
2783
+ if (opts.json) {
2784
+ process.stdout.write(JSON.stringify(parsed, null, 2) + "\n");
2785
+ return 0;
2786
+ }
2787
+ section(`Privacy details (declared in ${file})`);
2788
+ const config = parsed;
2789
+ if (!config.collectsData) {
2790
+ line(` ${BOLD}Data Not Collected${RESET}`);
2791
+ return 0;
2792
+ }
2793
+ for (const e of config.entries ?? []) {
2794
+ const flags = [
2795
+ e.usedForTracking ? "tracking" : "",
2796
+ e.linkedToUser ? "linked" : "",
2797
+ Array.isArray(e.purposes) ? e.purposes.join(",") : ""
2798
+ ].filter(Boolean).join(" \xB7 ");
2799
+ line(` ${BOLD}${String(e.category)}${RESET} ${DIM}${flags}${RESET}`);
2800
+ }
2801
+ return 0;
2352
2802
  }
2353
- async function runSandboxDelete(id) {
2803
+ async function runPrivacyLint(filePath) {
2804
+ let parsed;
2354
2805
  try {
2355
- const s = await client();
2356
- await s.sandboxTesters.delete(id);
2357
- section(`Deleted sandbox tester ${id}`);
2358
- ok("done");
2359
- return 0;
2806
+ parsed = JSON.parse(readFileSync(filePath, "utf8"));
2360
2807
  } catch (err) {
2361
- bad(err instanceof Error ? err.message : String(err));
2808
+ bad(`failed to read ${filePath}: ${err instanceof Error ? err.message : String(err)}`);
2362
2809
  return 1;
2363
2810
  }
2811
+ const issues = lintPrivacyConfig(parsed);
2812
+ const errors = issues.filter((i) => i.severity === "error");
2813
+ const warnings = issues.filter((i) => i.severity === "warning");
2814
+ section(`Privacy lint: ${filePath}`);
2815
+ for (const i of issues) {
2816
+ const tag = i.severity === "error" ? `${RED}error${RESET}` : `${YELLOW}warn${RESET}`;
2817
+ line(` ${tag} ${i.message}`);
2818
+ }
2819
+ if (errors.length === 0 && warnings.length === 0) {
2820
+ ok("clean");
2821
+ return 0;
2822
+ }
2823
+ line(`${BOLD}${errors.length}${RESET} error(s), ${BOLD}${warnings.length}${RESET} warning(s)`);
2824
+ return errors.length > 0 ? 1 : 0;
2364
2825
  }
2365
2826
 
2366
2827
  // src/lib/asc-testflight.ts
2367
- function testflight(client2) {
2828
+ function testflight(client) {
2368
2829
  return {
2369
- /* beta groups -------------------------------------------------------- */
2370
2830
  betaGroups: {
2371
2831
  list(filter) {
2372
2832
  const query = {};
2373
2833
  if (filter?.appId) query["filter[app]"] = filter.appId;
2374
2834
  if (filter?.name) query["filter[name]"] = filter.name;
2375
- return client2.paginatedList("/v1/betaGroups", query);
2835
+ return client.paginatedList("/v1/betaGroups", query);
2376
2836
  },
2377
2837
  async get(id) {
2378
- const res = await client2.request("GET", `/v1/betaGroups/${id}`);
2838
+ const res = await client.request("GET", `/v1/betaGroups/${id}`);
2379
2839
  return res.data;
2380
2840
  },
2381
2841
  async create(args) {
@@ -2384,8 +2844,6 @@ function testflight(client2) {
2384
2844
  type: "betaGroups",
2385
2845
  attributes: {
2386
2846
  name: args.name,
2387
- ...args.publicLinkEnabled !== void 0 ? { publicLinkEnabled: args.publicLinkEnabled } : {},
2388
- ...args.publicLinkLimit !== void 0 ? { publicLinkLimit: args.publicLinkLimit, publicLinkLimitEnabled: true } : {},
2389
2847
  ...args.feedbackEnabled !== void 0 ? { feedbackEnabled: args.feedbackEnabled } : {}
2390
2848
  },
2391
2849
  relationships: {
@@ -2393,39 +2851,30 @@ function testflight(client2) {
2393
2851
  }
2394
2852
  }
2395
2853
  };
2396
- const res = await client2.request("POST", "/v1/betaGroups", body);
2854
+ const res = await client.request("POST", "/v1/betaGroups", body);
2397
2855
  return res.data;
2398
2856
  },
2399
2857
  async delete(id) {
2400
- await client2.request("DELETE", `/v1/betaGroups/${id}`);
2858
+ await client.request("DELETE", `/v1/betaGroups/${id}`);
2401
2859
  },
2402
2860
  async listTesters(groupId) {
2403
- return client2.paginatedList(`/v1/betaGroups/${groupId}/betaTesters`);
2861
+ return client.paginatedList(`/v1/betaGroups/${groupId}/betaTesters`);
2404
2862
  },
2405
2863
  async addTesters(groupId, testerIds) {
2406
2864
  const body = { data: testerIds.map((id) => ({ type: "betaTesters", id })) };
2407
- await client2.request(
2865
+ await client.request(
2408
2866
  "POST",
2409
2867
  `/v1/betaGroups/${groupId}/relationships/betaTesters`,
2410
2868
  body
2411
2869
  );
2412
- },
2413
- async removeTesters(groupId, testerIds) {
2414
- const body = { data: testerIds.map((id) => ({ type: "betaTesters", id })) };
2415
- await client2.request(
2416
- "DELETE",
2417
- `/v1/betaGroups/${groupId}/relationships/betaTesters`,
2418
- body
2419
- );
2420
2870
  }
2421
2871
  },
2422
- /* beta testers ------------------------------------------------------- */
2423
2872
  betaTesters: {
2424
2873
  list(filter) {
2425
2874
  const query = {};
2426
2875
  if (filter?.email) query["filter[email]"] = filter.email;
2427
2876
  if (filter?.appId) query["filter[apps]"] = filter.appId;
2428
- return client2.paginatedList("/v1/betaTesters", query);
2877
+ return client.paginatedList("/v1/betaTesters", query);
2429
2878
  },
2430
2879
  async create(args) {
2431
2880
  const body = {
@@ -2450,14 +2899,10 @@ function testflight(client2) {
2450
2899
  }
2451
2900
  }
2452
2901
  };
2453
- const res = await client2.request("POST", "/v1/betaTesters", body);
2902
+ const res = await client.request("POST", "/v1/betaTesters", body);
2454
2903
  return res.data;
2455
- },
2456
- async delete(id) {
2457
- await client2.request("DELETE", `/v1/betaTesters/${id}`);
2458
2904
  }
2459
2905
  },
2460
- /* invitations -------------------------------------------------------- */
2461
2906
  betaTesterInvitations: {
2462
2907
  async create(args) {
2463
2908
  const body = {
@@ -2469,7 +2914,7 @@ function testflight(client2) {
2469
2914
  }
2470
2915
  }
2471
2916
  };
2472
- const res = await client2.request(
2917
+ const res = await client.request(
2473
2918
  "POST",
2474
2919
  "/v1/betaTesterInvitations",
2475
2920
  body
@@ -2477,15 +2922,9 @@ function testflight(client2) {
2477
2922
  return res.data;
2478
2923
  }
2479
2924
  },
2480
- /* beta build localizations ------------------------------------------ */
2481
2925
  betaBuildLocalizations: {
2482
- list(buildId) {
2483
- return client2.paginatedList(
2484
- `/v1/builds/${buildId}/betaBuildLocalizations`
2485
- );
2486
- },
2487
2926
  async upsert(args) {
2488
- const existing = await client2.paginatedList(
2927
+ const existing = await client.paginatedList(
2489
2928
  `/v1/builds/${args.buildId}/betaBuildLocalizations`,
2490
2929
  { "filter[locale]": args.locale }
2491
2930
  );
@@ -2497,7 +2936,7 @@ function testflight(client2) {
2497
2936
  attributes: { whatsNew: args.whatsNew }
2498
2937
  }
2499
2938
  };
2500
- const res2 = await client2.request(
2939
+ const res2 = await client.request(
2501
2940
  "PATCH",
2502
2941
  `/v1/betaBuildLocalizations/${existing[0].id}`,
2503
2942
  body2
@@ -2513,44 +2952,30 @@ function testflight(client2) {
2513
2952
  }
2514
2953
  }
2515
2954
  };
2516
- const res = await client2.request(
2955
+ const res = await client.request(
2517
2956
  "POST",
2518
2957
  "/v1/betaBuildLocalizations",
2519
2958
  body
2520
2959
  );
2521
2960
  return res.data;
2522
2961
  }
2523
- },
2524
- /* builds (read-only) ------------------------------------------------- */
2525
- builds: {
2526
- list(filter) {
2527
- const query = {};
2528
- if (filter?.appId) query["filter[app]"] = filter.appId;
2529
- if (filter?.version) query["filter[version]"] = filter.version;
2530
- if (filter?.processingState) query["filter[processingState]"] = filter.processingState;
2531
- return client2.paginatedList("/v1/builds", query, filter?.limit ?? 50);
2532
- },
2533
- async get(id) {
2534
- const res = await client2.request("GET", `/v1/builds/${id}`);
2535
- return res.data;
2536
- }
2537
2962
  }
2538
2963
  };
2539
2964
  }
2540
2965
 
2541
2966
  // src/commands/testflight.ts
2542
- async function bootstrap3() {
2543
- const { client: client2, ascAppId, bundleId } = await ascBootstrap();
2967
+ async function bootstrap() {
2968
+ const { client, ascAppId, bundleId } = await ascBootstrap();
2544
2969
  if (!ascAppId) {
2545
2970
  throw new Error(
2546
2971
  `no ASC app found for bundle id ${bundleId ?? "(unset)"}; run \`vexpo apple credentials\` first`
2547
2972
  );
2548
2973
  }
2549
- return { tf: testflight(client2), ascAppId };
2974
+ return { tf: testflight(client), ascAppId };
2550
2975
  }
2551
2976
  async function runTestflightGroupsList(opts = {}) {
2552
2977
  try {
2553
- const { tf, ascAppId } = await bootstrap3();
2978
+ const { tf, ascAppId } = await bootstrap();
2554
2979
  const groups = await tf.betaGroups.list({ appId: ascAppId });
2555
2980
  if (opts.json) {
2556
2981
  process.stdout.write(JSON.stringify(groups, null, 2) + "\n");
@@ -2574,17 +2999,14 @@ async function runTestflightGroupsList(opts = {}) {
2574
2999
  }
2575
3000
  async function runTestflightGroupsCreate(opts) {
2576
3001
  try {
2577
- const { tf, ascAppId } = await bootstrap3();
3002
+ const { tf, ascAppId } = await bootstrap();
2578
3003
  const created = await tf.betaGroups.create({
2579
3004
  name: opts.name,
2580
3005
  appId: ascAppId,
2581
- publicLinkEnabled: opts.publicLink,
2582
- publicLinkLimit: opts.publicLimit,
2583
3006
  feedbackEnabled: opts.feedback
2584
3007
  });
2585
3008
  section(`Beta group ${created.attributes.name}`);
2586
3009
  ok(`id ${created.id}`);
2587
- if (created.attributes.publicLink) line(` public link: ${created.attributes.publicLink}`);
2588
3010
  return 0;
2589
3011
  } catch (err) {
2590
3012
  bad(err instanceof Error ? err.message : String(err));
@@ -2593,7 +3015,7 @@ async function runTestflightGroupsCreate(opts) {
2593
3015
  }
2594
3016
  async function runTestflightGroupsView(groupId, opts) {
2595
3017
  try {
2596
- const { tf } = await bootstrap3();
3018
+ const { tf } = await bootstrap();
2597
3019
  const [group, testers] = await Promise.all([
2598
3020
  tf.betaGroups.get(groupId),
2599
3021
  tf.betaGroups.listTesters(groupId).catch(() => [])
@@ -2618,298 +3040,89 @@ async function runTestflightGroupsView(groupId, opts) {
2618
3040
  return 1;
2619
3041
  }
2620
3042
  }
2621
- async function runTestflightGroupsDelete(groupId) {
2622
- try {
2623
- const { tf } = await bootstrap3();
2624
- await tf.betaGroups.delete(groupId);
2625
- section(`Group ${groupId} deleted`);
2626
- ok("done");
2627
- return 0;
2628
- } catch (err) {
2629
- bad(err instanceof Error ? err.message : String(err));
2630
- return 1;
2631
- }
2632
- }
2633
- async function runTestflightTestersList(opts) {
2634
- try {
2635
- const { tf, ascAppId } = await bootstrap3();
2636
- const testers = await tf.betaTesters.list({ appId: ascAppId, email: opts.email });
2637
- if (opts.json) {
2638
- process.stdout.write(JSON.stringify(testers, null, 2) + "\n");
2639
- return 0;
2640
- }
2641
- section("Beta testers");
2642
- if (testers.length === 0) {
2643
- nop("none");
2644
- return 0;
2645
- }
2646
- for (const t of testers) {
2647
- const name = `${t.attributes.firstName ?? ""} ${t.attributes.lastName ?? ""}`.trim();
2648
- line(
2649
- ` ${BOLD}${t.attributes.email ?? "(no email)"}${RESET} ${name ? DIM + name + RESET + " " : ""}${DIM}${t.attributes.state ?? ""}${RESET}`
2650
- );
2651
- }
2652
- return 0;
2653
- } catch (err) {
2654
- bad(err instanceof Error ? err.message : String(err));
2655
- return 1;
2656
- }
2657
- }
2658
- async function runTestflightInvite(opts) {
2659
- try {
2660
- const { tf, ascAppId } = await bootstrap3();
2661
- const existing = await tf.betaTesters.list({ email: opts.email, appId: ascAppId });
2662
- let testerId = existing[0]?.id;
2663
- if (!testerId) {
2664
- const created = await tf.betaTesters.create({
2665
- email: opts.email,
2666
- firstName: opts.firstName,
2667
- lastName: opts.lastName,
2668
- appIds: [ascAppId],
2669
- groupIds: opts.groupId ? [opts.groupId] : []
2670
- });
2671
- testerId = created.id;
2672
- ok(`tester ${opts.email} added`);
2673
- } else {
2674
- ok(`tester ${opts.email} already exists (${testerId})`);
2675
- if (opts.groupId) await tf.betaGroups.addTesters(opts.groupId, [testerId]);
2676
- }
2677
- const inv = await tf.betaTesterInvitations.create({ appId: ascAppId, testerId });
2678
- section(`Invited ${opts.email}`);
2679
- ok(`invitation ${inv.id}`);
2680
- return 0;
2681
- } catch (err) {
2682
- bad(err instanceof Error ? err.message : String(err));
2683
- return 1;
2684
- }
2685
- }
2686
- async function runTestflightRemove(email) {
2687
- try {
2688
- const { tf, ascAppId } = await bootstrap3();
2689
- const matches = await tf.betaTesters.list({ email, appId: ascAppId });
2690
- if (matches.length === 0) {
2691
- bad(`no tester with email ${email}`);
2692
- return 1;
2693
- }
2694
- for (const t of matches) await tf.betaTesters.delete(t.id);
2695
- section(`Removed ${matches.length} tester${matches.length === 1 ? "" : "s"}`);
2696
- ok("done");
2697
- return 0;
2698
- } catch (err) {
2699
- bad(err instanceof Error ? err.message : String(err));
2700
- return 1;
2701
- }
2702
- }
2703
- async function runTestflightWhatsNew(opts) {
2704
- try {
2705
- const { tf } = await bootstrap3();
2706
- const loc = await tf.betaBuildLocalizations.upsert({
2707
- buildId: opts.buildId,
2708
- locale: opts.locale,
2709
- whatsNew: opts.text
2710
- });
2711
- section(`What's new for build ${opts.buildId}`);
2712
- ok(`upserted (${loc.attributes.locale})`);
2713
- return 0;
2714
- } catch (err) {
2715
- bad(err instanceof Error ? err.message : String(err));
2716
- return 1;
2717
- }
2718
- }
2719
-
2720
- // src/commands/better-auth.ts
2721
- function base64Secret() {
2722
- const buf = new Uint8Array(32);
2723
- crypto.getRandomValues(buf);
2724
- return btoa(String.fromCharCode(...buf));
2725
- }
2726
- async function runBetterAuth(options2) {
2727
- section("Better Auth env");
2728
- try {
2729
- const env2 = await envMap();
2730
- const siteUrl = options2.siteUrl ?? `${await scheme()}://`;
2731
- if (env2.has("SITE_URL") && env2.get("SITE_URL") === siteUrl) {
2732
- nop(`SITE_URL already set to ${siteUrl}`);
2733
- } else {
2734
- await envSet("SITE_URL", siteUrl);
2735
- ok(`set SITE_URL=${siteUrl}`);
2736
- }
2737
- if (env2.has("BETTER_AUTH_SECRET") && !options2.rotateSecret) {
2738
- nop("BETTER_AUTH_SECRET already set (use --rotate-secret to regenerate)");
2739
- } else {
2740
- await envSet("BETTER_AUTH_SECRET", base64Secret());
2741
- ok(
2742
- options2.rotateSecret === true ? "rotated BETTER_AUTH_SECRET (sessions invalidated)" : "generated BETTER_AUTH_SECRET"
2743
- );
2744
- }
2745
- const desiredAppName = options2.appName ?? await appName();
2746
- if (env2.has("APP_NAME") && env2.get("APP_NAME") === desiredAppName) {
2747
- nop(`APP_NAME already set to ${desiredAppName}`);
2748
- } else {
2749
- await envSet("APP_NAME", desiredAppName);
2750
- ok(`set APP_NAME=${desiredAppName}`);
2751
- }
2752
- await recordStep("better-auth", {
2753
- siteUrl,
2754
- appName: desiredAppName,
2755
- rotated: options2.rotateSecret === true
2756
- });
2757
- return 0;
2758
- } catch (err) {
2759
- bad(err instanceof Error ? err.message : String(err));
2760
- return 1;
2761
- }
2762
- }
2763
-
2764
- // src/commands/convex.ts
2765
- var BUNDLE_ID_RE = /^[A-Za-z0-9.-]+$/;
2766
- var TEAM_ID_RE = /^[A-Z0-9]{10}$/;
2767
- async function runConvex(options2) {
2768
- section("Convex deployment");
3043
+ async function runTestflightGroupsDelete(groupId) {
2769
3044
  try {
2770
- if (!await isLoggedIn()) {
2771
- yep("not signed in to Convex");
2772
- await helpAndWait({
2773
- body: "Sign up free and run `bunx convex login` in another terminal:",
2774
- urls: [
2775
- { label: "Convex sign-up", url: "https://convex.dev" },
2776
- { label: "Convex dashboard", url: "https://dashboard.convex.dev" }
2777
- ],
2778
- allowSkip: true,
2779
- skipLabel: "skip"
2780
- });
2781
- }
2782
- const localEnv = await readAll();
2783
- const existing = localEnv.get("CONVEX_DEPLOYMENT");
2784
- if (options2.fresh) {
2785
- await removeLines([
2786
- "CONVEX_DEPLOYMENT",
2787
- "EXPO_PUBLIC_CONVEX_URL",
2788
- "EXPO_PUBLIC_CONVEX_SITE_URL"
2789
- ]);
2790
- }
2791
- const needsProvisioning = options2.fresh === true || !existing;
2792
- const projectName = options2.name ?? await pkgName();
2793
- const cmd = [dlx(), "convex", "dev", "--once", "--tail-logs", "disable"];
2794
- if (options2.local) cmd.push("--local");
2795
- if (needsProvisioning) cmd.push("--configure", "new", "--project", projectName);
2796
- if (needsProvisioning) {
2797
- ok(`provisioning Convex project '${projectName}'`);
2798
- } else {
2799
- ok(`connecting to existing deployment ${existing}`);
2800
- }
2801
- const proc = spawn(cmd, { stdin: "inherit", stdout: "inherit", stderr: "inherit" });
2802
- if (await proc.exited !== 0) {
2803
- bad("convex dev exited with a non-zero code");
2804
- return 1;
2805
- }
2806
- const refreshed = await readAll();
2807
- const deployment = refreshed.get("CONVEX_DEPLOYMENT");
2808
- if (!deployment) {
2809
- bad("CONVEX_DEPLOYMENT missing after convex dev ran");
2810
- return 1;
2811
- }
2812
- const slug2 = deployment.split("#")[0].trim().split(":")[1];
2813
- if (!slug2) {
2814
- bad(`invalid CONVEX_DEPLOYMENT: ${deployment}`);
2815
- return 1;
2816
- }
2817
- process.env.CONVEX_DEPLOYMENT = deployment;
2818
- if (refreshed.has("EXPO_PUBLIC_CONVEX_URL")) {
2819
- nop("EXPO_PUBLIC_CONVEX_URL already set");
2820
- } else {
2821
- await ensureLine("EXPO_PUBLIC_CONVEX_URL", `https://${slug2}.convex.cloud`);
2822
- ok("wrote EXPO_PUBLIC_CONVEX_URL");
3045
+ const { tf } = await bootstrap();
3046
+ await tf.betaGroups.delete(groupId);
3047
+ section(`Group ${groupId} deleted`);
3048
+ ok("done");
3049
+ return 0;
3050
+ } catch (err) {
3051
+ bad(err instanceof Error ? err.message : String(err));
3052
+ return 1;
3053
+ }
3054
+ }
3055
+ async function runTestflightTestersList(opts) {
3056
+ try {
3057
+ const { tf, ascAppId } = await bootstrap();
3058
+ const testers = await tf.betaTesters.list({ appId: ascAppId, email: opts.email });
3059
+ if (opts.json) {
3060
+ process.stdout.write(JSON.stringify(testers, null, 2) + "\n");
3061
+ return 0;
2823
3062
  }
2824
- if (refreshed.has("EXPO_PUBLIC_CONVEX_SITE_URL")) {
2825
- nop("EXPO_PUBLIC_CONVEX_SITE_URL already set");
2826
- } else {
2827
- await ensureLine("EXPO_PUBLIC_CONVEX_SITE_URL", `https://${slug2}.convex.site`);
2828
- ok("wrote EXPO_PUBLIC_CONVEX_SITE_URL");
3063
+ section("Beta testers");
3064
+ if (testers.length === 0) {
3065
+ nop("none");
3066
+ return 0;
2829
3067
  }
2830
- if (refreshed.has("EXPO_PUBLIC_SITE_URL")) {
2831
- nop("EXPO_PUBLIC_SITE_URL already set");
2832
- } else {
2833
- const s = `${await scheme()}://`;
2834
- await ensureLine("EXPO_PUBLIC_SITE_URL", s);
2835
- ok(`wrote EXPO_PUBLIC_SITE_URL=${s}`);
3068
+ for (const t of testers) {
3069
+ const name = `${t.attributes.firstName ?? ""} ${t.attributes.lastName ?? ""}`.trim();
3070
+ line(
3071
+ ` ${BOLD}${t.attributes.email ?? "(no email)"}${RESET} ${name ? DIM + name + RESET + " " : ""}${DIM}${t.attributes.state ?? ""}${RESET}`
3072
+ );
2836
3073
  }
2837
- await ensureIdentity(refreshed);
2838
- await recordStep("convex", {
2839
- deployment,
2840
- slug: slug2,
2841
- ...options2.local ? { local: true } : {}
2842
- });
2843
- line();
2844
- ok(`Convex deployment ready: ${BOLD}${slug2}${RESET}`);
2845
- note(`dashboard: https://dashboard.convex.dev/d/${slug2}`);
2846
3074
  return 0;
2847
3075
  } catch (err) {
2848
3076
  bad(err instanceof Error ? err.message : String(err));
2849
3077
  return 1;
2850
3078
  }
2851
3079
  }
2852
- async function ensureIdentity(localEnv) {
2853
- const haveBundle = localEnv.has("EXPO_PUBLIC_APP_BUNDLE_ID");
2854
- const haveTeam = localEnv.has("EXPO_PUBLIC_APPLE_TEAM_ID");
2855
- let bundleId = localEnv.get("EXPO_PUBLIC_APP_BUNDLE_ID");
2856
- let teamId = localEnv.get("EXPO_PUBLIC_APPLE_TEAM_ID");
2857
- if (!haveBundle) {
2858
- if (!process.stdin.isTTY) {
2859
- yep("EXPO_PUBLIC_APP_BUNDLE_ID not set (non-TTY); skipping prompt");
2860
- yep("set it in .env.local before running `vexpo apple` or building");
2861
- } else {
2862
- const fromConfig = await bundleIdFallback();
2863
- const isTemplate = !fromConfig || fromConfig.startsWith("com.example.");
2864
- const suggested = isTemplate ? `com.example.${await pkgName()}` : fromConfig;
2865
- const cachedHint = isTemplate ? "" : ` ${DIM}(from app.config.ts)${RESET}`;
2866
- const raw = (await ask(
2867
- ` iOS bundle id ${DIM}(reverse-DNS, e.g. com.you.app)${RESET}${cachedHint}
2868
- ${DIM}> ${suggested} ${RESET}`
2869
- )).trim();
2870
- bundleId = raw || suggested;
2871
- if (!BUNDLE_ID_RE.test(bundleId)) {
2872
- throw new Error(`invalid bundle id '${bundleId}' (allowed: A-Z a-z 0-9 . -)`);
2873
- }
2874
- await ensureLine("EXPO_PUBLIC_APP_BUNDLE_ID", bundleId);
2875
- ok(`wrote EXPO_PUBLIC_APP_BUNDLE_ID=${bundleId}`);
2876
- }
2877
- } else {
2878
- nop(`EXPO_PUBLIC_APP_BUNDLE_ID already set (${bundleId})`);
2879
- }
2880
- if (!haveTeam) {
2881
- if (!process.stdin.isTTY) {
2882
- yep("EXPO_PUBLIC_APPLE_TEAM_ID not set (non-TTY); skipping prompt");
3080
+ async function runTestflightInvite(opts) {
3081
+ try {
3082
+ const { tf, ascAppId } = await bootstrap();
3083
+ const existing = await tf.betaTesters.list({ email: opts.email, appId: ascAppId });
3084
+ let testerId = existing[0]?.id;
3085
+ if (!testerId) {
3086
+ const created = await tf.betaTesters.create({
3087
+ email: opts.email,
3088
+ firstName: opts.firstName,
3089
+ lastName: opts.lastName,
3090
+ appIds: [ascAppId],
3091
+ groupIds: opts.groupId ? [opts.groupId] : []
3092
+ });
3093
+ testerId = created.id;
3094
+ ok(`tester ${opts.email} added`);
2883
3095
  } else {
2884
- const fromConfig = await appleTeamIdFallback();
2885
- const cachedHint = fromConfig ? ` ${DIM}[${fromConfig} from app.config.ts]${RESET}` : "";
2886
- const raw = (await ask(
2887
- ` Apple Team id ${DIM}(10-char alphanumeric, find at developer.apple.com/account)${RESET}${cachedHint}
2888
- ${DIM}> ${RESET}`
2889
- )).trim().toUpperCase();
2890
- const value = raw || (fromConfig ?? "");
2891
- if (!TEAM_ID_RE.test(value)) {
2892
- throw new Error(`invalid Apple Team id '${value}' (must be 10 uppercase alphanumeric)`);
2893
- }
2894
- teamId = value;
2895
- await ensureLine("EXPO_PUBLIC_APPLE_TEAM_ID", teamId);
2896
- ok(`wrote EXPO_PUBLIC_APPLE_TEAM_ID=${teamId}`);
3096
+ ok(`tester ${opts.email} already exists (${testerId})`);
3097
+ if (opts.groupId) await tf.betaGroups.addTesters(opts.groupId, [testerId]);
2897
3098
  }
2898
- } else {
2899
- nop(`EXPO_PUBLIC_APPLE_TEAM_ID already set (${teamId})`);
2900
- }
2901
- if (bundleId) {
2902
- await envSet("APP_BUNDLE_ID", bundleId);
2903
- ok(`Convex env: APP_BUNDLE_ID=${bundleId}`);
3099
+ const inv = await tf.betaTesterInvitations.create({ appId: ascAppId, testerId });
3100
+ section(`Invited ${opts.email}`);
3101
+ ok(`invitation ${inv.id}`);
3102
+ return 0;
3103
+ } catch (err) {
3104
+ bad(err instanceof Error ? err.message : String(err));
3105
+ return 1;
2904
3106
  }
2905
- if (teamId) {
2906
- await envSet("APPLE_TEAM_ID", teamId);
2907
- ok(`Convex env: APPLE_TEAM_ID=${teamId}`);
3107
+ }
3108
+ async function runTestflightWhatsNew(opts) {
3109
+ try {
3110
+ const { tf } = await bootstrap();
3111
+ const loc = await tf.betaBuildLocalizations.upsert({
3112
+ buildId: opts.buildId,
3113
+ locale: opts.locale,
3114
+ whatsNew: opts.text
3115
+ });
3116
+ section(`What's new for build ${opts.buildId}`);
3117
+ ok(`upserted (${loc.attributes.locale})`);
3118
+ return 0;
3119
+ } catch (err) {
3120
+ bad(err instanceof Error ? err.message : String(err));
3121
+ return 1;
2908
3122
  }
2909
3123
  }
2910
3124
  var easEnvFor = (channel) => channel === "prod" ? ["production", "preview"] : ["development"];
2911
3125
  var ROUTING = {
2912
- // EAS-only (build-time public)
2913
3126
  EXPO_PUBLIC_CONVEX_URL: {
2914
3127
  routes: (c) => [{ type: "eas", key: "EXPO_PUBLIC_CONVEX_URL", environments: easEnvFor(c) }]
2915
3128
  },
@@ -2930,7 +3143,6 @@ var ROUTING = {
2930
3143
  EXPO_PUBLIC_EXPO_OWNER: {
2931
3144
  routes: (c) => [{ type: "eas", key: "EXPO_PUBLIC_EXPO_OWNER", environments: easEnvFor(c) }]
2932
3145
  },
2933
- // Convex-bound server-side
2934
3146
  SITE_URL: { routes: (c) => [{ type: "convex", key: "SITE_URL", channel: c }] },
2935
3147
  BETTER_AUTH_SECRET: {
2936
3148
  routes: (c) => [{ type: "convex", key: "BETTER_AUTH_SECRET", channel: c }]
@@ -2967,7 +3179,7 @@ var MANUAL_EAS_SECRETS = {
2967
3179
  APPLE_P8_PRIVATE_KEY: "eas env:create --name APPLE_P8_PRIVATE_KEY --value-file <path>.p8 --environment production --visibility secret",
2968
3180
  CONVEX_DEPLOY_KEY: "eas env:create --name CONVEX_DEPLOY_KEY --value <prod-deploy-key> --environment production --visibility secret"
2969
3181
  };
2970
- async function fileExists4(p) {
3182
+ async function fileExists3(p) {
2971
3183
  try {
2972
3184
  await access(p);
2973
3185
  return true;
@@ -2977,7 +3189,7 @@ async function fileExists4(p) {
2977
3189
  }
2978
3190
  async function readEnvFile(path) {
2979
3191
  const out = /* @__PURE__ */ new Map();
2980
- if (!await fileExists4(path)) return out;
3192
+ if (!await fileExists3(path)) return out;
2981
3193
  const text = await readFile(path, "utf8");
2982
3194
  let buffer = "";
2983
3195
  let pendingKey = null;
@@ -3026,13 +3238,13 @@ async function readSources(paths) {
3026
3238
  const local = paths?.local ?? ".env.local";
3027
3239
  const prodCandidates = paths?.prod ? [paths.prod] : [".env.prod", ".env.production"];
3028
3240
  const sources = [];
3029
- if (await fileExists4(local)) {
3241
+ if (await fileExists3(local)) {
3030
3242
  sources.push({ path: local, channel: "dev", entries: await readEnvFile(local) });
3031
3243
  } else if (paths?.local) {
3032
3244
  throw new Error(`--local-file path does not exist: ${paths.local}`);
3033
3245
  }
3034
3246
  for (const p of prodCandidates) {
3035
- if (await fileExists4(p)) {
3247
+ if (await fileExists3(p)) {
3036
3248
  sources.push({ path: p, channel: "prod", entries: await readEnvFile(p) });
3037
3249
  break;
3038
3250
  }
@@ -3082,7 +3294,90 @@ function missingKeys(sources) {
3082
3294
  return { dev: [...dev].toSorted(), prod: [...prod].toSorted() };
3083
3295
  }
3084
3296
 
3085
- // src/lib/verify.ts
3297
+ // src/commands/convex-migrate.ts
3298
+ function selectMigratableEnv(src, dst) {
3299
+ const out = [];
3300
+ for (const [key, value] of src) {
3301
+ if (key.startsWith("CONVEX_")) continue;
3302
+ if (dst.get(key) === value) continue;
3303
+ out.push([key, value]);
3304
+ }
3305
+ return out;
3306
+ }
3307
+ async function fileExists4(p) {
3308
+ try {
3309
+ await access(p);
3310
+ return true;
3311
+ } catch {
3312
+ return false;
3313
+ }
3314
+ }
3315
+ async function runConvexMigrate(options2) {
3316
+ const channel = options2.prod ? "prod" : "dev";
3317
+ section(`Convex migrate (${channel})`);
3318
+ if (!options2.from) {
3319
+ bad("--from <deployment> is required (the source deployment slug)");
3320
+ return 1;
3321
+ }
3322
+ const fromSlug = deploymentSlug(options2.from) ?? options2.from;
3323
+ let target;
3324
+ if (options2.prod) {
3325
+ const prodFile = await fileExists4(".env.prod") ? ".env.prod" : ".env.production";
3326
+ const prodEnv = await readEnvFile(prodFile);
3327
+ const deployKey = prodEnv.get("CONVEX_DEPLOY_KEY") ?? "";
3328
+ const selector = prodEnv.get("CONVEX_DEPLOYMENT") ?? "";
3329
+ if (!deployKey.startsWith("prod:") && !selector.startsWith("prod:")) {
3330
+ bad(`${prodFile} has no prod-scoped CONVEX_DEPLOY_KEY or CONVEX_DEPLOYMENT`);
3331
+ note("the copy would land on the DEV deployment (the dev key shadows --prod)");
3332
+ return 1;
3333
+ }
3334
+ target = { prod: true, envFile: prodFile };
3335
+ }
3336
+ const src = await envMap({ deployment: fromSlug });
3337
+ if (src.size === 0) {
3338
+ bad(`no env on source deployment ${fromSlug} (unreachable or empty)`);
3339
+ note("pass a deployment slug your account can reach, e.g. `--from old-deployment-123`");
3340
+ return 1;
3341
+ }
3342
+ const dst = await envMap(target);
3343
+ const toMove = selectMigratableEnv(src, dst);
3344
+ if (toMove.length === 0) {
3345
+ ok(`target already matches ${fromSlug} (nothing to copy)`);
3346
+ return 0;
3347
+ }
3348
+ line();
3349
+ note(
3350
+ `${BOLD}${toMove.length}${RESET} server-side var${toMove.length === 1 ? "" : "s"} to copy from ${fromSlug}:`
3351
+ );
3352
+ for (const [key] of toMove) note(` ${key}`);
3353
+ if (options2.dryRun) {
3354
+ line();
3355
+ note("--dry-run; exiting without changes");
3356
+ return 0;
3357
+ }
3358
+ let failed = 0;
3359
+ for (const [key, value] of toMove) {
3360
+ try {
3361
+ await envSet(key, value, target);
3362
+ ok(`copied ${key}`);
3363
+ } catch (err) {
3364
+ bad(`${key} failed: ${err instanceof Error ? err.message : err}`);
3365
+ failed += 1;
3366
+ }
3367
+ }
3368
+ line();
3369
+ if (failed > 0) {
3370
+ bad(`${failed} write${failed === 1 ? "" : "s"} failed`);
3371
+ return 1;
3372
+ }
3373
+ ok(
3374
+ `migrated ${toMove.length} var${toMove.length === 1 ? "" : "s"} onto the ${channel} deployment`
3375
+ );
3376
+ note(`next: ${BOLD}vexpo env convex-key${RESET} (EAS deploy key + selector)`);
3377
+ note(` ${BOLD}vexpo resend --repoint${options2.prod ? " --prod" : ""}${RESET} (webhook)`);
3378
+ note(`then: ${BOLD}vexpo doctor --channel ${channel}${RESET}`);
3379
+ return 0;
3380
+ }
3086
3381
  var ok2 = (category, name, message, details) => ({
3087
3382
  category,
3088
3383
  name,
@@ -3150,6 +3445,16 @@ async function verifyConvex(ctx) {
3150
3445
  const checks = [];
3151
3446
  const env2 = ctx.channel === "prod" ? ctx.convexProdEnv : ctx.convexEnv;
3152
3447
  const local = ctx.channel === "prod" ? ctx.envProd : ctx.envLocal;
3448
+ if (local.get("CONVEX_DEPLOYMENT")) {
3449
+ const status = await checkToken();
3450
+ if (status === "unauthorized") {
3451
+ checks.push(
3452
+ fail2("convex", "login", "Convex token expired or revoked", "run `npx convex login`")
3453
+ );
3454
+ } else if (status === "valid") {
3455
+ checks.push(ok2("convex", "login", "token valid"));
3456
+ }
3457
+ }
3153
3458
  const cloudUrl = local.get("EXPO_PUBLIC_CONVEX_URL");
3154
3459
  const siteUrl = local.get("EXPO_PUBLIC_CONVEX_SITE_URL");
3155
3460
  if (cloudUrl) {
@@ -3201,6 +3506,25 @@ async function verifyConvex(ctx) {
3201
3506
  } else {
3202
3507
  checks.push(fail2("convex", "better-auth-secret", `not set on Convex (${ctx.channel})`));
3203
3508
  }
3509
+ const deploymentName2 = deploymentSlug(local.get("CONVEX_DEPLOYMENT"));
3510
+ if (deploymentName2) {
3511
+ const deployments = await listProjectDeployments(deploymentName2);
3512
+ if (deployments) {
3513
+ const devs = deploymentsOfType(deployments, "dev");
3514
+ if (devs.length > 1) {
3515
+ checks.push(
3516
+ warn(
3517
+ "convex",
3518
+ "deployments",
3519
+ `${devs.length} dev deployments in this project`,
3520
+ `${devs.map(describeDeployment).join(", ")} \u2014 pick one canonical, delete the others`
3521
+ )
3522
+ );
3523
+ } else {
3524
+ checks.push(ok2("convex", "deployments", `${deployments.length} total, 1 dev`));
3525
+ }
3526
+ }
3527
+ }
3204
3528
  return checks;
3205
3529
  }
3206
3530
  async function verifyResend(ctx) {
@@ -3213,21 +3537,21 @@ async function verifyResend(ctx) {
3213
3537
  if (!apiKey) {
3214
3538
  const requireEmailVerification = env2.get("REQUIRE_EMAIL_VERIFICATION");
3215
3539
  if (!requireEmailVerification || requireEmailVerification === "false") {
3216
- checks.push(skip("resend", "api-key-set", "lite mode (run `bunx vexpo full` to provision)"));
3540
+ checks.push(skip("resend", "api-key-set", "lite mode (run `npx vexpo full` to provision)"));
3217
3541
  return checks;
3218
3542
  }
3219
3543
  checks.push(fail2("resend", "api-key-set", `RESEND_API_KEY not set on Convex (${ctx.channel})`));
3220
3544
  return checks;
3221
3545
  }
3222
- const access14 = await probeAccess(apiKey);
3223
- if (access14 === "invalid") {
3546
+ const access17 = await probeAccess(apiKey);
3547
+ if (access17 === "invalid") {
3224
3548
  checks.push(fail2("resend", "api-key-valid", "RESEND_API_KEY rejected by Resend"));
3225
3549
  return checks;
3226
3550
  }
3227
- checks.push(ok2("resend", "api-key-valid", `key authenticated (access=${access14})`));
3551
+ checks.push(ok2("resend", "api-key-valid", `key authenticated (access=${access17})`));
3228
3552
  let domains = [];
3229
3553
  let webhooks = [];
3230
- if (access14 === "full") {
3554
+ if (access17 === "full") {
3231
3555
  try {
3232
3556
  domains = await listDomains(apiKey);
3233
3557
  } catch (e) {
@@ -3295,15 +3619,29 @@ async function verifyResend(ctx) {
3295
3619
  const expectedEndpoint = `${expectedSiteUrl.replace(/\/$/, "")}/resend-webhook`;
3296
3620
  const match = webhooks.find((w) => w.endpoint === expectedEndpoint);
3297
3621
  if (!match) {
3298
- const others = webhooks.map((w) => w.endpoint).join(", ");
3299
- checks.push(
3300
- warn(
3301
- "resend",
3302
- "webhook-endpoint",
3303
- `no webhook pointing at ${expectedEndpoint}`,
3304
- others ? `existing: ${others}` : void 0
3305
- )
3622
+ const others = webhooks.map((w) => w.endpoint);
3623
+ const staleConvex = others.filter(
3624
+ (e) => e.includes(".convex.site") && e.endsWith("/resend-webhook")
3306
3625
  );
3626
+ if (staleConvex.length > 0) {
3627
+ checks.push(
3628
+ warn(
3629
+ "resend",
3630
+ "webhook-endpoint",
3631
+ `no webhook for this deployment; ${staleConvex.length} point at other convex.site deployments (stale after a deployment migration)`,
3632
+ `run \`vexpo resend --repoint${ctx.channel === "prod" ? " --prod" : ""}\` to move it to ${expectedEndpoint} and realign RESEND_WEBHOOK_SECRET. stale: ${staleConvex.join(", ")}`
3633
+ )
3634
+ );
3635
+ } else {
3636
+ checks.push(
3637
+ warn(
3638
+ "resend",
3639
+ "webhook-endpoint",
3640
+ `no webhook pointing at ${expectedEndpoint}`,
3641
+ others.length ? `existing: ${others.join(", ")}` : void 0
3642
+ )
3643
+ );
3644
+ }
3307
3645
  } else if (match.status !== "enabled" && match.status !== "active") {
3308
3646
  checks.push(warn("resend", "webhook-endpoint", `webhook ${match.id} status=${match.status}`));
3309
3647
  } else {
@@ -3321,7 +3659,7 @@ async function verifyResend(ctx) {
3321
3659
  "resend",
3322
3660
  "webhook-events",
3323
3661
  `webhook missing ${missing.join(", ")}`,
3324
- "re-run `bunx vexpo resend` to refresh subscription"
3662
+ "re-run `npx vexpo resend` to refresh subscription"
3325
3663
  )
3326
3664
  );
3327
3665
  }
@@ -3398,10 +3736,10 @@ async function verifyApple(ctx) {
3398
3736
  const v = await validate(ctx.ascCreds);
3399
3737
  if (v.ok) {
3400
3738
  checks.push(ok2("apple", "asc-key-valid", `${v.appCount} app${v.appCount === 1 ? "" : "s"}`));
3401
- const client2 = makeAscClient(ctx.ascCreds);
3739
+ const client = makeAscClient(ctx.ascCreds);
3402
3740
  if (servicesId) {
3403
3741
  try {
3404
- const matches = await client2.bundleIds.list({ identifier: servicesId });
3742
+ const matches = await client.bundleIds.list({ identifier: servicesId });
3405
3743
  if (matches.length > 0)
3406
3744
  checks.push(ok2("apple", "services-id-exists", `${servicesId} found in ASC`));
3407
3745
  else
@@ -3410,7 +3748,7 @@ async function verifyApple(ctx) {
3410
3748
  "apple",
3411
3749
  "services-id-exists",
3412
3750
  `${servicesId} not found in App Store Connect`,
3413
- "run `bunx vexpo apple services-id` to provision it"
3751
+ "run `npx vexpo apple services-id` to provision it"
3414
3752
  )
3415
3753
  );
3416
3754
  } catch (e) {
@@ -3423,44 +3761,12 @@ async function verifyApple(ctx) {
3423
3761
  );
3424
3762
  }
3425
3763
  }
3426
- const bundleId = ctx.envLocal.get("EXPO_PUBLIC_APP_BUNDLE_ID") ?? ctx.appConfig.bundleIdFallback ?? void 0;
3427
- if (bundleId) {
3428
- try {
3429
- const apps = await client2.paginatedList(
3430
- "/v1/apps",
3431
- { "filter[bundleId]": bundleId },
3432
- 5
3433
- );
3434
- const ascAppId = apps[0]?.id;
3435
- if (ascAppId) {
3436
- const { reviews: reviewsApi, unansweredOlderThan: unansweredOlderThan2 } = await import('./asc-reviews-OPKN34SB.js');
3437
- const all = await reviewsApi(client2).customerReviews.list({
3438
- appId: ascAppId,
3439
- limit: 100
3440
- });
3441
- const stale = unansweredOlderThan2(all, 7);
3442
- if (stale.length === 0)
3443
- checks.push(ok2("apple", "reviews-answered", "no stale reviews"));
3444
- else
3445
- checks.push(
3446
- warn(
3447
- "apple",
3448
- "reviews-answered",
3449
- `${stale.length} review${stale.length === 1 ? "" : "s"} unanswered for >7 days`,
3450
- "run `vexpo reviews unanswered --days 7` to triage"
3451
- )
3452
- );
3453
- }
3454
- } catch {
3455
- checks.push(skip("apple", "reviews-answered", "could not query customer reviews"));
3456
- }
3457
- }
3458
3764
  } else {
3459
3765
  checks.push(fail2("apple", "asc-key-valid", v.reason));
3460
3766
  }
3461
3767
  } else {
3462
3768
  checks.push(
3463
- skip("apple", "asc-key-valid", "no cached ASC creds (run `bunx vexpo apple asc-key`)")
3769
+ skip("apple", "asc-key-valid", "no cached ASC creds (run `npx vexpo apple asc-key`)")
3464
3770
  );
3465
3771
  }
3466
3772
  return checks;
@@ -3469,100 +3775,84 @@ async function verifyEas(ctx) {
3469
3775
  const checks = [];
3470
3776
  let projectId = null;
3471
3777
  try {
3472
- projectId = await projectIdFromAppJson();
3778
+ projectId = await resolveProjectId();
3473
3779
  } catch {
3474
- checks.push(skip("eas", "project-id", "couldn't read app.json"));
3475
- return checks;
3476
3780
  }
3477
3781
  if (!projectId) {
3478
3782
  const env2 = ctx.channel === "prod" ? ctx.convexProdEnv : ctx.convexEnv;
3479
- const requireEmailVerification = env2.get("REQUIRE_EMAIL_VERIFICATION");
3480
- if (!requireEmailVerification || requireEmailVerification === "false") {
3481
- checks.push(skip("eas", "project-id", "lite mode (run `bunx vexpo full` to init EAS)"));
3783
+ const rev = env2.get("REQUIRE_EMAIL_VERIFICATION");
3784
+ if (!rev || rev === "false") {
3785
+ checks.push(skip("eas", "project-id", "lite mode (run `npx vexpo full` to init EAS)"));
3482
3786
  return checks;
3483
3787
  }
3484
- checks.push(fail2("eas", "project-id", "no projectId in app.json"));
3485
- return checks;
3486
3788
  }
3487
- checks.push(ok2("eas", "project-id", projectId));
3488
- let who = null;
3489
- try {
3490
- who = await whoami();
3491
- } catch {
3492
- checks.push(skip("eas", "signed-in", "eas CLI not available"));
3493
- return checks;
3789
+ const envNames = ["production", "preview", "development"];
3790
+ const envMaps = /* @__PURE__ */ new Map();
3791
+ for (const e of envNames) {
3792
+ try {
3793
+ envMaps.set(e, await envList(e));
3794
+ } catch {
3795
+ envMaps.set(e, null);
3796
+ }
3494
3797
  }
3495
- if (!who) {
3496
- checks.push(warn("eas", "signed-in", "not signed in (run `bunx eas login`)"));
3798
+ const provisioned = [...envMaps.values()].some((m) => m !== null && m.size > 0);
3799
+ if (projectId) {
3800
+ checks.push(ok2("eas", "project-id", projectId));
3801
+ } else if (provisioned) {
3802
+ checks.push(
3803
+ warn(
3804
+ "eas",
3805
+ "project-id",
3806
+ "EAS env is provisioned but projectId is unresolved",
3807
+ "set EAS_PROJECT_ID in .env.local (app.json is intentionally stubbed)"
3808
+ )
3809
+ );
3810
+ } else {
3811
+ checks.push(
3812
+ fail2("eas", "project-id", "no projectId in app.json, EAS_PROJECT_ID env, or .env.local")
3813
+ );
3497
3814
  return checks;
3498
3815
  }
3499
- checks.push(ok2("eas", "signed-in", who));
3500
- try {
3501
- const info = await projectInfo();
3502
- if (info) {
3503
- if (info.id === projectId) {
3504
- checks.push(ok2("eas", "project-info", info.fullName));
3505
- } else {
3816
+ if (projectId) {
3817
+ try {
3818
+ const who = await whoami();
3819
+ checks.push(
3820
+ who ? ok2("eas", "signed-in", who) : warn("eas", "signed-in", "not signed in (run `npx eas login`)")
3821
+ );
3822
+ } catch {
3823
+ checks.push(skip("eas", "signed-in", "eas CLI not available"));
3824
+ }
3825
+ try {
3826
+ const info = await projectInfo();
3827
+ if (info && info.id === projectId) checks.push(ok2("eas", "project-info", info.fullName));
3828
+ else if (info)
3506
3829
  checks.push(
3507
3830
  fail2(
3508
3831
  "eas",
3509
3832
  "project-info",
3510
- `app.json projectId (${projectId}) doesn't match resolved project (${info.id})`,
3833
+ `local projectId (${projectId}) doesn't match resolved project (${info.id})`,
3511
3834
  "run `vexpo eas` to re-link"
3512
3835
  )
3513
3836
  );
3514
- }
3515
- } else {
3516
- checks.push(
3517
- warn(
3518
- "eas",
3519
- "project-info",
3520
- "eas project:info failed (project may have been deleted or transferred)"
3521
- )
3522
- );
3523
- }
3524
- } catch {
3525
- checks.push(skip("eas", "project-info", "eas-cli not available"));
3526
- }
3527
- try {
3528
- const diag = await diagnostics();
3529
- if (diag.ok) {
3530
- checks.push(ok2("eas", "diagnostics", "eas-cli health ok"));
3531
- } else {
3532
- checks.push(warn("eas", "diagnostics", diag.error));
3837
+ else
3838
+ checks.push(
3839
+ warn("eas", "project-info", "eas project:info failed (project deleted or transferred?)")
3840
+ );
3841
+ } catch {
3842
+ checks.push(skip("eas", "project-info", "eas-cli not available"));
3533
3843
  }
3534
- } catch {
3535
- checks.push(skip("eas", "diagnostics", "eas-cli not available"));
3536
- }
3537
- try {
3538
- const { ascStatus: ascStatus2 } = await import('./eas-integrations-TIYBWWKC.js');
3539
- const asc = await ascStatus2();
3540
- if (asc.connected) {
3541
- const label = asc.ascApp?.bundleId ?? asc.ascApp?.id ?? "ok";
3542
- checks.push(ok2("eas", "asc-integration", String(label)));
3543
- } else {
3844
+ try {
3845
+ const diag = await diagnostics();
3544
3846
  checks.push(
3545
- warn(
3546
- "eas",
3547
- "asc-integration",
3548
- "no ASC integration on EAS",
3549
- "run `vexpo asc connect` to link"
3550
- )
3847
+ diag.ok ? ok2("eas", "diagnostics", "eas-cli health ok") : warn("eas", "diagnostics", diag.error)
3551
3848
  );
3849
+ } catch {
3850
+ checks.push(skip("eas", "diagnostics", "eas-cli not available"));
3552
3851
  }
3553
- } catch {
3554
- checks.push(skip("eas", "asc-integration", "eas integrations:asc:status unavailable"));
3555
3852
  }
3556
- const envs = [
3557
- "production",
3558
- "preview",
3559
- "development"
3560
- ];
3561
- for (const env2 of envs) {
3562
- let list;
3563
- try {
3564
- list = await envList(env2);
3565
- } catch {
3853
+ for (const env2 of envNames) {
3854
+ const list = envMaps.get(env2) ?? null;
3855
+ if (!list) {
3566
3856
  checks.push(skip("eas", `env-${env2}`, "eas env:list unavailable"));
3567
3857
  continue;
3568
3858
  }
@@ -3575,9 +3865,29 @@ async function verifyEas(ctx) {
3575
3865
  "eas",
3576
3866
  `env-${env2}`,
3577
3867
  `missing ${missing.join(", ")}`,
3578
- "run `bunx vexpo full` to init EAS + mirror env"
3868
+ "run `npx vexpo full` to init EAS + mirror env"
3579
3869
  )
3580
3870
  );
3871
+ const expected = (env2 === "development" ? ctx.envLocal : ctx.envProd).get(
3872
+ "EXPO_PUBLIC_CONVEX_URL"
3873
+ );
3874
+ const actual = list.get("EXPO_PUBLIC_CONVEX_URL");
3875
+ if (expected && actual) {
3876
+ const expSlug = deploymentSlugFromHost(hostnameOf(expected) ?? "");
3877
+ const actSlug = deploymentSlugFromHost(hostnameOf(actual) ?? "");
3878
+ if (expSlug && actSlug && expSlug !== actSlug) {
3879
+ checks.push(
3880
+ fail2(
3881
+ "eas",
3882
+ `convex-url-${env2}`,
3883
+ `EAS points at ${actSlug}, local at ${expSlug}`,
3884
+ "run `vexpo env push` + `vexpo env convex-key` to repoint EAS at the active deployment"
3885
+ )
3886
+ );
3887
+ } else if (expSlug && actSlug) {
3888
+ checks.push(ok2("eas", `convex-url-${env2}`, `points at ${actSlug}`));
3889
+ }
3890
+ }
3581
3891
  if (env2 === "production") {
3582
3892
  const rotationSecrets = [
3583
3893
  "CONVEX_DEPLOY_KEY",
@@ -3600,6 +3910,38 @@ async function verifyEas(ctx) {
3600
3910
  );
3601
3911
  }
3602
3912
  }
3913
+ try {
3914
+ const status = await ascStatus();
3915
+ if (status.status === "connected") {
3916
+ checks.push(
3917
+ ok2("eas", "asc-integration", status.appStoreConnectApp?.bundleIdentifier ?? "connected")
3918
+ );
3919
+ const missing = existsSync("eas.json") ? submitProfilesMissingAscAppId(readFileSync("eas.json", "utf8")) : [];
3920
+ if (missing.length > 0) {
3921
+ checks.push(
3922
+ warn(
3923
+ "eas",
3924
+ "asc-submit-id",
3925
+ `submit profile${missing.length === 1 ? "" : "s"} ${missing.join(", ")} missing ascAppId`,
3926
+ "run `vexpo asc` to write it; non-interactive `eas submit` (CI) fails without it"
3927
+ )
3928
+ );
3929
+ } else if (existsSync("eas.json")) {
3930
+ checks.push(ok2("eas", "asc-submit-id", "submit profiles carry ascAppId"));
3931
+ }
3932
+ } else {
3933
+ checks.push(
3934
+ warn(
3935
+ "eas",
3936
+ "asc-integration",
3937
+ `not connected (${status.status})`,
3938
+ "run `vexpo asc:connect` so `eas submit` resolves the app from the bundle id"
3939
+ )
3940
+ );
3941
+ }
3942
+ } catch {
3943
+ checks.push(skip("eas", "asc-integration", "eas integrations:asc:status unavailable"));
3944
+ }
3603
3945
  return checks;
3604
3946
  }
3605
3947
  function verifyCoherence(ctx) {
@@ -3715,12 +4057,15 @@ function verifyFiles(ctx) {
3715
4057
  return checks;
3716
4058
  }
3717
4059
  async function readContext(channel) {
4060
+ const prodEnvFile = existsSync(".env.prod") ? ".env.prod" : existsSync(".env.production") ? ".env.production" : void 0;
3718
4061
  const [envLocal, envProd, convexEnv, convexProdEnv, appConfigFacts, ascCreds] = await Promise.all(
3719
4062
  [
3720
4063
  readEnvFile(".env.local"),
3721
4064
  readEnvFile(".env.prod").then(async (m) => m.size > 0 ? m : readEnvFile(".env.production")),
3722
4065
  envMap().catch(() => /* @__PURE__ */ new Map()),
3723
- envMap({ prod: true }).catch(() => /* @__PURE__ */ new Map()),
4066
+ prodEnvFile ? envMap({ prod: true, envFile: prodEnvFile }).catch(
4067
+ () => /* @__PURE__ */ new Map()
4068
+ ) : Promise.resolve(/* @__PURE__ */ new Map()),
3724
4069
  readAppConfigFacts(),
3725
4070
  loadAscCreds3()
3726
4071
  ]
@@ -3884,6 +4229,113 @@ async function runDoctor(options2) {
3884
4229
  return 2;
3885
4230
  }
3886
4231
  }
4232
+ async function fileExists5(p) {
4233
+ try {
4234
+ await access(p);
4235
+ return true;
4236
+ } catch {
4237
+ return false;
4238
+ }
4239
+ }
4240
+ async function upsert(name, value, visibility, env2) {
4241
+ const existing = await envList(env2);
4242
+ if (existing.has(name)) await envUpdate(name, value, visibility, [env2]);
4243
+ else await envCreate(name, value, visibility, [env2]);
4244
+ }
4245
+ async function runConvexKey(options2) {
4246
+ section("EAS Convex key");
4247
+ const projectId = await resolveProjectId();
4248
+ if (!projectId) {
4249
+ bad("no EAS projectId. run `eas init` (or `vexpo full`) first");
4250
+ return 1;
4251
+ }
4252
+ ok(`EAS project: ${BOLD}${projectId}${RESET}`);
4253
+ const localFile = options2.localFile ?? ".env.local";
4254
+ const prodFile = options2.prodFile ?? (await fileExists5(".env.prod") ? ".env.prod" : ".env.production");
4255
+ const local = await readEnvFile(localFile);
4256
+ const prod = await readEnvFile(prodFile);
4257
+ const devKey = options2.devKey ?? local.get("CONVEX_DEPLOY_KEY");
4258
+ let prodKey = options2.prodKey ?? prod.get("CONVEX_DEPLOY_KEY");
4259
+ const devSel = local.get("CONVEX_DEPLOYMENT");
4260
+ const prodSel = prod.get("CONVEX_DEPLOYMENT");
4261
+ if (options2.mint && !prodKey) {
4262
+ const easProd = await envList("production").catch(() => /* @__PURE__ */ new Map());
4263
+ if (easProd.has("CONVEX_DEPLOY_KEY")) {
4264
+ note("prod CONVEX_DEPLOY_KEY already on EAS; skipping mint");
4265
+ } else {
4266
+ const slug2 = deploymentSlug(prodSel ?? devSel);
4267
+ const minted = slug2 ? await mintProdDeployKey(slug2, "convex-key").catch(() => null) : null;
4268
+ if (minted) {
4269
+ prodKey = minted.key;
4270
+ ok(`minted prod deploy key for ${BOLD}${minted.deployment}${RESET}`);
4271
+ } else {
4272
+ yep("--mint: couldn't resolve the prod deployment to mint a key");
4273
+ }
4274
+ }
4275
+ }
4276
+ if (devKey && !devKey.startsWith("dev:"))
4277
+ yep("dev deploy key is not dev-scoped (expected dev:\u2026)");
4278
+ if (prodKey && !prodKey.startsWith("prod:"))
4279
+ yep("prod deploy key is not prod-scoped (expected prod:\u2026)");
4280
+ const writes = [];
4281
+ if (devKey)
4282
+ writes.push({
4283
+ name: "CONVEX_DEPLOY_KEY",
4284
+ value: devKey,
4285
+ visibility: "secret",
4286
+ envs: ["development"],
4287
+ label: "dev deploy key"
4288
+ });
4289
+ if (prodKey)
4290
+ writes.push({
4291
+ name: "CONVEX_DEPLOY_KEY",
4292
+ value: prodKey,
4293
+ visibility: "secret",
4294
+ envs: ["production"],
4295
+ label: "prod deploy key"
4296
+ });
4297
+ if (devSel)
4298
+ writes.push({
4299
+ name: "CONVEX_DEPLOYMENT",
4300
+ value: devSel,
4301
+ visibility: "plaintext",
4302
+ envs: ["development"],
4303
+ label: "dev selector"
4304
+ });
4305
+ if (prodSel)
4306
+ writes.push({
4307
+ name: "CONVEX_DEPLOYMENT",
4308
+ value: prodSel,
4309
+ visibility: "plaintext",
4310
+ envs: ["production", "preview"],
4311
+ label: "prod selector"
4312
+ });
4313
+ if (writes.length === 0) {
4314
+ yep("no CONVEX_DEPLOY_KEY / CONVEX_DEPLOYMENT found in env files or flags");
4315
+ note("pass --dev-key / --prod-key, or set them in .env.local / .env.prod");
4316
+ return 1;
4317
+ }
4318
+ let failed = 0;
4319
+ for (const w of writes) {
4320
+ for (const env2 of w.envs) {
4321
+ try {
4322
+ await upsert(w.name, w.value, w.visibility, env2);
4323
+ ok(`${env2}: ${w.name} ${DIM}(${w.label})${RESET}`);
4324
+ } catch (err) {
4325
+ bad(`${env2}: ${w.name} failed: ${err instanceof Error ? err.message : err}`);
4326
+ failed += 1;
4327
+ }
4328
+ }
4329
+ }
4330
+ line();
4331
+ if (failed > 0) {
4332
+ bad(`${failed} write${failed === 1 ? "" : "s"} failed`);
4333
+ return 1;
4334
+ }
4335
+ ok("EAS Convex key + selector synced");
4336
+ note("`vexpo doctor` to confirm EAS now points at the active deployment");
4337
+ return 0;
4338
+ }
3887
4339
 
3888
4340
  // src/commands/env/push.ts
3889
4341
  function shortValue(v) {
@@ -3894,12 +4346,12 @@ function describeDest(d) {
3894
4346
  if (d.type === "convex") return `convex env (${d.channel}) \u2192 ${d.key}`;
3895
4347
  return `eas env (${d.environments.join(",")}) \u2192 ${d.key}`;
3896
4348
  }
3897
- async function readRemoteState() {
3898
- const projectId = await projectIdFromAppJson();
4349
+ async function readRemoteState(prodEnvFile) {
4350
+ const projectId = await resolveProjectId();
3899
4351
  const hasEasProject = !!projectId;
3900
4352
  const [convexDev, convexProd, easDev, easPreview, easProd] = await Promise.all([
3901
4353
  envMap().catch(() => /* @__PURE__ */ new Map()),
3902
- envMap({ prod: true }).catch(() => /* @__PURE__ */ new Map()),
4354
+ envMap({ prod: true, envFile: prodEnvFile }).catch(() => /* @__PURE__ */ new Map()),
3903
4355
  hasEasProject ? envList("development").catch(() => /* @__PURE__ */ new Map()) : Promise.resolve(/* @__PURE__ */ new Map()),
3904
4356
  hasEasProject ? envList("preview").catch(() => /* @__PURE__ */ new Map()) : Promise.resolve(/* @__PURE__ */ new Map()),
3905
4357
  hasEasProject ? envList("production").catch(() => /* @__PURE__ */ new Map()) : Promise.resolve(/* @__PURE__ */ new Map())
@@ -3997,15 +4449,22 @@ async function applyPlan(plan, opts = {}) {
3997
4449
  }
3998
4450
  let applied = 0;
3999
4451
  let failed = 0;
4000
- const { writeFile: writeFile4, unlink: unlink2 } = await import('fs/promises');
4452
+ const { writeFile: writeFile4, unlink: unlink2, mkdtemp, rmdir } = await import('fs/promises');
4453
+ const { tmpdir } = await import('os');
4454
+ const { join: join2 } = await import('path');
4001
4455
  for (const [channel, entries] of convexBatches) {
4002
4456
  if (entries.length === 0) continue;
4003
- const tmp = `.tmp-convex-${channel}-${process.pid}.env`;
4457
+ const dir = await mkdtemp(join2(tmpdir(), "vexpo-env-"));
4458
+ const tmp = join2(dir, "convex.env");
4004
4459
  try {
4005
- await writeFile4(tmp, entries.map(([k, v]) => `${k}=${v}`).join("\n") + "\n");
4006
- await envSetFromFile(tmp, channel === "prod" ? { prod: true } : void 0, {
4007
- force: opts.force ?? false
4460
+ await writeFile4(tmp, entries.map(([k, v]) => `${k}=${v}`).join("\n") + "\n", {
4461
+ mode: 384
4008
4462
  });
4463
+ await envSetFromFile(
4464
+ tmp,
4465
+ channel === "prod" ? { prod: true, envFile: plan.sourceFile } : void 0,
4466
+ { force: opts.force ?? false }
4467
+ );
4009
4468
  ok(`convex(${channel}) bulk-set ${entries.length} var${entries.length === 1 ? "" : "s"}`);
4010
4469
  for (const [k] of entries) note(` ${k}`);
4011
4470
  applied += entries.length;
@@ -4015,13 +4474,18 @@ async function applyPlan(plan, opts = {}) {
4015
4474
  } finally {
4016
4475
  await unlink2(tmp).catch(() => {
4017
4476
  });
4477
+ await rmdir(dir).catch(() => {
4478
+ });
4018
4479
  }
4019
4480
  }
4020
4481
  for (const { envs, entries } of easBatches.values()) {
4021
4482
  if (entries.length === 0) continue;
4022
- const tmp = `.tmp-eas-${envs.join("-")}-${process.pid}.env`;
4483
+ const dir = await mkdtemp(join2(tmpdir(), "vexpo-env-"));
4484
+ const tmp = join2(dir, "eas.env");
4023
4485
  try {
4024
- await writeFile4(tmp, entries.map(([k, v]) => `${k}=${v}`).join("\n") + "\n");
4486
+ await writeFile4(tmp, entries.map(([k, v]) => `${k}=${v}`).join("\n") + "\n", {
4487
+ mode: 384
4488
+ });
4025
4489
  await envPush({ path: tmp, environments: envs, force: true });
4026
4490
  ok(`eas(${envs.join(",")}) pushed ${entries.length} var${entries.length === 1 ? "" : "s"}`);
4027
4491
  for (const [k] of entries) note(` ${k}`);
@@ -4032,12 +4496,19 @@ async function applyPlan(plan, opts = {}) {
4032
4496
  } finally {
4033
4497
  await unlink2(tmp).catch(() => {
4034
4498
  });
4499
+ await rmdir(dir).catch(() => {
4500
+ });
4035
4501
  }
4036
4502
  }
4037
4503
  return { applied, failed };
4038
4504
  }
4039
4505
  async function runEnvPush(options2) {
4040
4506
  section("Env push");
4507
+ if (await checkToken() === "unauthorized") {
4508
+ bad("Convex login expired or revoked");
4509
+ note("run `npx convex login` to refresh, then re-run");
4510
+ return 1;
4511
+ }
4041
4512
  const sources = await readSources({ local: options2.localFile, prod: options2.prodFile });
4042
4513
  if (sources.length === 0) {
4043
4514
  yep("no source files found");
@@ -4069,18 +4540,23 @@ async function runEnvPush(options2) {
4069
4540
  );
4070
4541
  }
4071
4542
  }
4072
- const remote = await readRemoteState();
4543
+ const prodEnvFile = sources.find((s) => s.channel === "prod")?.path;
4544
+ const remote = await readRemoteState(prodEnvFile);
4073
4545
  if (!remote.hasEasProject) yep("no EAS projectId in app.json. EAS env routes will be blocked");
4074
- const prodSource = sources.find((s) => s.channel === "prod");
4075
- const manualHits = prodSource ? Object.keys(MANUAL_EAS_SECRETS).filter((k) => prodSource.entries.has(k)) : [];
4546
+ const manualHits = [];
4547
+ for (const s of sources) {
4548
+ for (const k of Object.keys(MANUAL_EAS_SECRETS)) {
4549
+ if (s.entries.has(k)) manualHits.push({ key: k, file: s.path });
4550
+ }
4551
+ }
4076
4552
  if (manualHits.length > 0) {
4077
4553
  line();
4078
4554
  yep(
4079
4555
  `${manualHits.length} secret-visibility key${manualHits.length === 1 ? "" : "s"} detected. set manually:`
4080
4556
  );
4081
- for (const k of manualHits) {
4082
- note(` ${BOLD}${k}${RESET}`);
4083
- note(` ${DIM}${MANUAL_EAS_SECRETS[k]}${RESET}`);
4557
+ for (const { key, file } of manualHits) {
4558
+ note(` ${BOLD}${key}${RESET} ${DIM}(${file})${RESET}`);
4559
+ note(` ${DIM}${MANUAL_EAS_SECRETS[key]}${RESET}`);
4084
4560
  }
4085
4561
  note(`${DIM}lite skips these to avoid pushing secrets at default visibility${RESET}`);
4086
4562
  }
@@ -4122,6 +4598,21 @@ async function runEnvPush(options2) {
4122
4598
  await recordStep("accounts", { mode: "lite", verifiedAt: (/* @__PURE__ */ new Date()).toISOString() });
4123
4599
  return 0;
4124
4600
  }
4601
+ const prodConvexWrites = entries.some(
4602
+ (e) => e.channel === "prod" && e.destinations.some((d) => d.type === "convex")
4603
+ );
4604
+ if (prodConvexWrites) {
4605
+ const pf = sources.find((s) => s.channel === "prod");
4606
+ const deployKey = pf?.entries.get("CONVEX_DEPLOY_KEY") ?? "";
4607
+ const selector = pf?.entries.get("CONVEX_DEPLOYMENT") ?? "";
4608
+ if (!deployKey.startsWith("prod:") && !selector.startsWith("prod:")) {
4609
+ line();
4610
+ bad(`${pf?.path ?? "prod source"} has no prod-scoped CONVEX_DEPLOY_KEY or CONVEX_DEPLOYMENT`);
4611
+ note("prod env would silently write to the DEV deployment (the dev key shadows --prod)");
4612
+ note("add a `prod:` CONVEX_DEPLOY_KEY (or CONVEX_DEPLOYMENT) to the prod file and re-run");
4613
+ return 1;
4614
+ }
4615
+ }
4125
4616
  line();
4126
4617
  if (totalConflicts > 0) {
4127
4618
  note(
@@ -4190,7 +4681,7 @@ async function runEnvPush(options2) {
4190
4681
  }
4191
4682
  function printVerifyResults(checks) {
4192
4683
  const w = Math.max(...checks.map((c) => c.name.length));
4193
- const order = ["files", "convex", "resend", "apple", "eas", "github", "coherence"];
4684
+ const order = ["files", "convex", "resend", "apple", "eas", "coherence"];
4194
4685
  const grouped = /* @__PURE__ */ new Map();
4195
4686
  for (const c of checks) {
4196
4687
  if (!grouped.has(c.category)) grouped.set(c.category, []);
@@ -4458,6 +4949,10 @@ async function runRebrand(options2) {
4458
4949
  await rewriteAppJson();
4459
4950
  await rewritePackageJson(inputs);
4460
4951
  await rewriteStoreConfig(inputs);
4952
+ if (inputs.expoOwner) {
4953
+ await ensureLine("EXPO_PUBLIC_EXPO_OWNER", inputs.expoOwner);
4954
+ ok(`wrote EXPO_PUBLIC_EXPO_OWNER=${inputs.expoOwner} to .env.local`);
4955
+ }
4461
4956
  await recordStep("rebrand", {
4462
4957
  appName: inputs.appName,
4463
4958
  packageName: inputs.packageName,
@@ -4508,7 +5003,27 @@ function formatElapsed(ms) {
4508
5003
  }
4509
5004
 
4510
5005
  // src/commands/resend.ts
5006
+ async function fileExists6(p) {
5007
+ try {
5008
+ await access(p);
5009
+ return true;
5010
+ } catch {
5011
+ return false;
5012
+ }
5013
+ }
5014
+ async function resolveFullKey() {
5015
+ const fromEnv = process.env.RESEND_FULL_ACCESS_KEY;
5016
+ if (fromEnv) return fromEnv;
5017
+ if (!process.stdin.isTTY) return null;
5018
+ line();
5019
+ note("Need a Resend full-access API key. Create one at:");
5020
+ note(` ${BOLD}https://resend.com/api-keys${RESET} \u2192 Create API Key \u2192 Permission: Full Access`);
5021
+ note("Used once, never persisted.");
5022
+ const pasted = await ask(` RESEND_FULL_ACCESS_KEY > `);
5023
+ return pasted || null;
5024
+ }
4511
5025
  async function runResend(options2) {
5026
+ if (options2.repoint) return runResendRepoint(options2);
4512
5027
  section("Resend provisioning");
4513
5028
  const siteUrl = await readOne("EXPO_PUBLIC_CONVEX_SITE_URL");
4514
5029
  if (!siteUrl) {
@@ -4535,9 +5050,9 @@ async function runResend(options2) {
4535
5050
  return 1;
4536
5051
  }
4537
5052
  }
4538
- const access14 = await probeAccess(fullKey);
4539
- if (access14 !== "full") {
4540
- bad(`provided key has '${access14}' access; need 'full'`);
5053
+ const keyAccess = await probeAccess(fullKey);
5054
+ if (keyAccess !== "full") {
5055
+ bad(`provided key has '${keyAccess}' access; need 'full'`);
4541
5056
  return 1;
4542
5057
  }
4543
5058
  ok("full-access key verified");
@@ -4619,7 +5134,7 @@ async function runResend(options2) {
4619
5134
  const token = await provisionSendingKey(fullKey, name, domain.id);
4620
5135
  ok(`scoped sending key '${name}' provisioned`);
4621
5136
  const endpoint = `${siteUrl.replace(/\/$/, "")}/resend-webhook`;
4622
- const secret = await provisionWebhook(fullKey, endpoint);
5137
+ const { id: webhookId, secret } = await provisionWebhook(fullKey, endpoint);
4623
5138
  ok(`webhook \u2192 ${endpoint}`);
4624
5139
  const fromAddr = options2.from ?? `${name}@${domain.name}`;
4625
5140
  await envSet("RESEND_API_KEY", token);
@@ -4637,7 +5152,8 @@ async function runResend(options2) {
4637
5152
  domainName: domain.name,
4638
5153
  keyName: name,
4639
5154
  fromAddress: fromAddr,
4640
- webhookEndpoint: endpoint
5155
+ webhookEndpoint: endpoint,
5156
+ webhookId
4641
5157
  });
4642
5158
  line();
4643
5159
  ok("Resend provisioning complete");
@@ -4648,6 +5164,64 @@ async function runResend(options2) {
4648
5164
  );
4649
5165
  return 0;
4650
5166
  }
5167
+ async function runResendRepoint(options2) {
5168
+ const channel = options2.prod ? "prod" : "dev";
5169
+ section(`Resend repoint (${channel})`);
5170
+ let siteUrl;
5171
+ let convexTarget;
5172
+ if (options2.prod) {
5173
+ const prodFile = await fileExists6(".env.prod") ? ".env.prod" : ".env.production";
5174
+ siteUrl = (await readEnvFile(prodFile)).get("EXPO_PUBLIC_CONVEX_SITE_URL");
5175
+ convexTarget = { prod: true, envFile: prodFile };
5176
+ } else {
5177
+ siteUrl = await readOne("EXPO_PUBLIC_CONVEX_SITE_URL") ?? void 0;
5178
+ }
5179
+ if (!siteUrl) {
5180
+ bad(`EXPO_PUBLIC_CONVEX_SITE_URL missing from ${options2.prod ? ".env.prod" : ".env.local"}`);
5181
+ note("run `vexpo convex` (and a prod deploy) so the site URL is populated, then re-run");
5182
+ return 1;
5183
+ }
5184
+ const endpoint = `${siteUrl.replace(/\/$/, "")}/resend-webhook`;
5185
+ ok(`target endpoint: ${endpoint}`);
5186
+ const fullKey = await resolveFullKey();
5187
+ if (!fullKey) {
5188
+ bad("no RESEND_FULL_ACCESS_KEY env var and no TTY for paste");
5189
+ return 1;
5190
+ }
5191
+ if (await probeAccess(fullKey) !== "full") {
5192
+ bad("provided key does not have full access");
5193
+ return 1;
5194
+ }
5195
+ const hooks = await listWebhooks(fullKey);
5196
+ const atNew = hooks.find((w) => w.endpoint === endpoint);
5197
+ const stale = hooks.filter(
5198
+ (w) => w.endpoint !== endpoint && w.endpoint.endsWith("/resend-webhook")
5199
+ );
5200
+ let webhookId;
5201
+ if (atNew && !options2.force) {
5202
+ ok(`webhook already points at ${endpoint}`);
5203
+ note("its secret can't be read back; pass --force to recreate + realign RESEND_WEBHOOK_SECRET");
5204
+ webhookId = atNew.id;
5205
+ } else {
5206
+ const { id, secret } = await provisionWebhook(fullKey, endpoint);
5207
+ webhookId = id;
5208
+ ok(`webhook \u2192 ${endpoint}`);
5209
+ await envSet("RESEND_WEBHOOK_SECRET", secret, convexTarget);
5210
+ ok(`RESEND_WEBHOOK_SECRET aligned on the ${channel} deployment`);
5211
+ }
5212
+ let retired = 0;
5213
+ for (const w of stale) {
5214
+ await deleteWebhook(fullKey, w.id);
5215
+ note(`retired stale webhook \u2192 ${w.endpoint}`);
5216
+ retired += 1;
5217
+ }
5218
+ const prev = (await load()).steps.resend?.outputs ?? {};
5219
+ await recordStep("resend", { ...prev, webhookEndpoint: endpoint, webhookId });
5220
+ line();
5221
+ ok(`repoint complete${retired ? ` (${retired} stale retired)` : ""}`);
5222
+ nop("sending key and REQUIRE_EMAIL_VERIFICATION left unchanged");
5223
+ return 0;
5224
+ }
4651
5225
  async function runReviewAccount(options2) {
4652
5226
  section("App Review demo account");
4653
5227
  try {
@@ -4668,27 +5242,17 @@ async function runReviewAccount(options2) {
4668
5242
  name,
4669
5243
  ...options2.username ? { username: options2.username } : {}
4670
5244
  });
4671
- const tryRun = async (extraArgs) => {
4672
- const proc = spawn(
4673
- [dlx(), "convex", "run", ...extraArgs, "admin:createReviewAccount", payload],
4674
- { stdin: "ignore", stdout: "pipe", stderr: "pipe" }
4675
- );
4676
- const code = await proc.exited;
4677
- return {
4678
- ok: code === 0,
4679
- out: await streamText(proc.stdout),
4680
- err: await streamText(proc.stderr)
4681
- };
4682
- };
4683
- let result = await tryRun(["--component-function"]);
4684
- if (!result.ok) result = await tryRun([]);
4685
- if (!result.ok) {
5245
+ const { code, stdout, stderr } = await run(
5246
+ [dlx(), "convex", "run", "admin:createReviewAccount", payload],
5247
+ { stdin: "ignore" }
5248
+ );
5249
+ if (code !== 0) {
4686
5250
  bad("convex run failed");
4687
- const stderr = result.err.trim();
4688
- if (stderr) note(stderr);
5251
+ const trimmed = stderr.trim();
5252
+ if (trimmed) note(trimmed);
4689
5253
  return 1;
4690
5254
  }
4691
- process.stderr.write(result.out);
5255
+ process.stderr.write(stdout);
4692
5256
  line();
4693
5257
  ok("review account ready, Apple's reviewer can now sign in");
4694
5258
  note(`email: ${email}`);
@@ -4700,61 +5264,7 @@ async function runReviewAccount(options2) {
4700
5264
  return 1;
4701
5265
  }
4702
5266
  }
4703
-
4704
- // src/commands/asc.ts
4705
- async function runAscConnect(opts) {
4706
- section("ASC connect");
4707
- if (!opts.force) {
4708
- try {
4709
- const status = await ascStatus();
4710
- if (status.connected) {
4711
- const label = status.ascApp?.bundleId ?? status.ascApp?.id ?? "ok";
4712
- nop(`already connected (${label})`);
4713
- await recordStep("apple-asc-link", {
4714
- ascAppId: status.ascApp?.id,
4715
- bundleId: status.ascApp?.bundleId,
4716
- connectedAt: (/* @__PURE__ */ new Date()).toISOString()
4717
- });
4718
- return 0;
4719
- }
4720
- } catch {
4721
- }
4722
- }
4723
- const apiKeyId = opts.apiKeyId ?? await ascKeyIdFromState();
4724
- const bundleId = opts.bundleId ?? await readOne("EXPO_PUBLIC_APP_BUNDLE_ID");
4725
- if (!apiKeyId) {
4726
- yep("no --api-key-id and no cached ASC key id in state.json");
4727
- note("run `vexpo apple asc-key` first, or pass --api-key-id");
4728
- }
4729
- if (!bundleId) {
4730
- yep("no --bundle-id and no EXPO_PUBLIC_APP_BUNDLE_ID in .env.local");
4731
- note("run `vexpo convex` first, or pass --bundle-id");
4732
- }
4733
- const exit = await ascConnect({
4734
- apiKeyId,
4735
- ascAppId: opts.ascAppId,
4736
- bundleId
4737
- });
4738
- if (exit !== 0) {
4739
- bad(`eas integrations:asc:connect exited with ${exit}`);
4740
- return exit;
4741
- }
4742
- ok("EAS project linked to ASC app");
4743
- await recordStep("apple-asc-link", {
4744
- ascApiKeyId: apiKeyId,
4745
- bundleId,
4746
- connectedAt: (/* @__PURE__ */ new Date()).toISOString()
4747
- });
4748
- return 0;
4749
- }
4750
- async function ascKeyIdFromState() {
4751
- const state = await load();
4752
- const rec = state.steps["asc-key"];
4753
- if (!rec?.outputs) return void 0;
4754
- const keyId = rec.outputs.keyId;
4755
- return typeof keyId === "string" ? keyId : void 0;
4756
- }
4757
- async function fileExists5(p) {
5267
+ async function fileExists7(p) {
4758
5268
  try {
4759
5269
  await access(p);
4760
5270
  return true;
@@ -4762,19 +5272,42 @@ async function fileExists5(p) {
4762
5272
  return false;
4763
5273
  }
4764
5274
  }
5275
+ async function pushEasRoutedKeys(file, environments) {
5276
+ const entries = await readEnvFile(file);
5277
+ const easKeys = [];
5278
+ for (const [key, value] of entries) {
5279
+ if (ROUTING[key]?.routes("dev").some((d) => d.type === "eas")) easKeys.push([key, value]);
5280
+ }
5281
+ if (easKeys.length === 0) return [];
5282
+ const { writeFile: writeFile4, unlink: unlink2, mkdtemp, rmdir } = await import('fs/promises');
5283
+ const { tmpdir } = await import('os');
5284
+ const { join: join2 } = await import('path');
5285
+ const dir = await mkdtemp(join2(tmpdir(), "vexpo-env-"));
5286
+ const tmp = join2(dir, "eas.env");
5287
+ try {
5288
+ await writeFile4(tmp, easKeys.map(([k, v]) => `${k}=${v}`).join("\n") + "\n", { mode: 384 });
5289
+ await envPush({ path: tmp, environments, force: true });
5290
+ return easKeys.map(([k]) => k);
5291
+ } finally {
5292
+ await unlink2(tmp).catch(() => {
5293
+ });
5294
+ await rmdir(dir).catch(() => {
5295
+ });
5296
+ }
5297
+ }
4765
5298
  async function runEas(options2) {
4766
5299
  section("EAS");
4767
5300
  try {
4768
5301
  const cli = await checkCli();
4769
5302
  if (!cli.ok) {
4770
- bad("eas CLI not available. install with `bun add -g eas-cli`");
5303
+ bad("eas CLI not available. install with `npm install -g eas-cli`");
4771
5304
  return 1;
4772
5305
  }
4773
5306
  ok(`eas-cli ${cli.version}`);
4774
5307
  const who = await whoami();
4775
5308
  if (!who) {
4776
5309
  if (!process.stdin.isTTY) {
4777
- bad("non-TTY: run `bunx eas login` then re-run");
5310
+ bad("non-TTY: run `npx eas login` then re-run");
4778
5311
  return 1;
4779
5312
  }
4780
5313
  yep("not signed in to Expo");
@@ -4792,7 +5325,7 @@ async function runEas(options2) {
4792
5325
  } else {
4793
5326
  ok(`signed in as ${BOLD}${who}${RESET}`);
4794
5327
  }
4795
- let projectId = await projectIdFromAppJson();
5328
+ let projectId = await resolveProjectId();
4796
5329
  if (!options2.skipInit) {
4797
5330
  if (projectId) {
4798
5331
  ok(`EAS project linked: ${projectId}`);
@@ -4815,10 +5348,16 @@ async function runEas(options2) {
4815
5348
  else nop(`branches already exist (${branches.join(", ")})`);
4816
5349
  }
4817
5350
  if (!options2.skipEnv) {
4818
- if (await fileExists5(".env.local")) {
5351
+ if (await fileExists7(".env.local")) {
4819
5352
  try {
4820
- await envPush({ path: ".env.local", environments: ["development"], force: true });
4821
- ok(`pushed .env.local \u2192 EAS env (development)`);
5353
+ const pushed = await pushEasRoutedKeys(".env.local", ["development"]);
5354
+ if (pushed.length > 0) {
5355
+ ok(
5356
+ `pushed ${pushed.length} EXPO_PUBLIC_* var${pushed.length === 1 ? "" : "s"} \u2192 EAS env (development)`
5357
+ );
5358
+ } else {
5359
+ nop(".env.local has no EAS-routed keys yet (run `vexpo convex` first)");
5360
+ }
4822
5361
  } catch (err) {
4823
5362
  bad(err instanceof Error ? err.message : String(err));
4824
5363
  }
@@ -4826,15 +5365,17 @@ async function runEas(options2) {
4826
5365
  nop(".env.local missing. skipping development env push (run `vexpo convex` first)");
4827
5366
  }
4828
5367
  if (options2.withProd) {
4829
- const prodFile = await fileExists5(".env.prod") ? ".env.prod" : await fileExists5(".env.production") ? ".env.production" : null;
5368
+ const prodFile = await fileExists7(".env.prod") ? ".env.prod" : await fileExists7(".env.production") ? ".env.production" : null;
4830
5369
  if (prodFile) {
4831
5370
  try {
4832
- await envPush({
4833
- path: prodFile,
4834
- environments: ["production", "preview"],
4835
- force: true
4836
- });
4837
- ok(`pushed ${prodFile} \u2192 EAS env (production, preview)`);
5371
+ const pushed = await pushEasRoutedKeys(prodFile, ["production", "preview"]);
5372
+ if (pushed.length > 0) {
5373
+ ok(
5374
+ `pushed ${pushed.length} EXPO_PUBLIC_* var${pushed.length === 1 ? "" : "s"} \u2192 EAS env (production, preview)`
5375
+ );
5376
+ } else {
5377
+ nop(`${prodFile} has no EAS-routed keys`);
5378
+ }
4838
5379
  } catch (err) {
4839
5380
  bad(err instanceof Error ? err.message : String(err));
4840
5381
  }
@@ -4842,6 +5383,9 @@ async function runEas(options2) {
4842
5383
  nop("--with-prod set but no .env.prod or .env.production found");
4843
5384
  }
4844
5385
  }
5386
+ note(
5387
+ `server-side secrets route to Convex, not EAS. run ${BOLD}vexpo env push${RESET} to sync those`
5388
+ );
4845
5389
  }
4846
5390
  if (projectId) {
4847
5391
  await recordStep("eas", {
@@ -4853,16 +5397,14 @@ async function runEas(options2) {
4853
5397
  line();
4854
5398
  note(`${BOLD}Next, eas-cli (we don't replace these)${RESET}`);
4855
5399
  note(
4856
- ` ${BOLD}bunx eas credentials -p ios${RESET} dist cert + profile + push key + ASC API key`
5400
+ ` ${BOLD}npx eas credentials -p ios${RESET} dist cert + profile + push key + ASC API key`
4857
5401
  );
4858
- note(` ${BOLD}bunx eas build -p ios --profile production${RESET}`);
5402
+ note(` ${BOLD}npx eas build -p ios --profile production${RESET}`);
4859
5403
  note(
4860
- ` ${BOLD}bunx eas submit -p ios --profile production${RESET} (auto-creates App Store record)`
4861
- );
4862
- note(` ${BOLD}bunx eas metadata:push${RESET} push store.config.json`);
4863
- note(
4864
- ` ${BOLD}bunx eas workflow:run .eas/workflows/<file>${RESET} trigger a workflow locally`
5404
+ ` ${BOLD}npx eas submit -p ios --profile production${RESET} (auto-creates App Store record)`
4865
5405
  );
5406
+ note(` ${BOLD}npx eas metadata:push${RESET} push store.config.json`);
5407
+ note(` ${BOLD}npx eas workflow:run .eas/workflows/<file>${RESET} trigger a workflow locally`);
4866
5408
  line();
4867
5409
  note(`${BOLD}Stack-specific (ours, not eas-cli's)${RESET}`);
4868
5410
  note(` ${BOLD}vexpo apple asc-key${RESET} validate ASC API key against /v1/apps`);
@@ -4880,10 +5422,6 @@ function computeScope(o) {
4880
5422
  const lite = o.lite === true;
4881
5423
  const isNew = o.isNew === true;
4882
5424
  return {
4883
- // Accounts walkthrough is educational only. Default mode skips it because
4884
- // users with existing accounts don't need it. `--new` opts in. In lite
4885
- // mode + --new, the walkthrough is limited to the Convex signup (runAccounts
4886
- // gets `lite: true` and short-circuits past Apple/Domain/Expo/Resend).
4887
5425
  accounts: isNew,
4888
5426
  rebrand: !lite && !o.skipRebrand,
4889
5427
  resend: !lite,
@@ -4901,7 +5439,7 @@ async function isXcodeInstalled() {
4901
5439
  });
4902
5440
  return await proc.exited === 0;
4903
5441
  }
4904
- async function fileExists6(p) {
5442
+ async function fileExists8(p) {
4905
5443
  try {
4906
5444
  await access(p);
4907
5445
  return true;
@@ -4928,7 +5466,7 @@ async function trashPaths(paths) {
4928
5466
  }
4929
5467
  async function wipeMetroCaches(tmpdir) {
4930
5468
  const { readdir } = await import('fs/promises');
4931
- const { join } = await import('path');
5469
+ const { join: join2 } = await import('path');
4932
5470
  let entries = [];
4933
5471
  try {
4934
5472
  entries = await readdir(tmpdir);
@@ -4936,11 +5474,11 @@ async function wipeMetroCaches(tmpdir) {
4936
5474
  return;
4937
5475
  }
4938
5476
  const matchers = [/^metro-/, /^haste-map-/, /^react-/, /^node-compile-cache$/];
4939
- const targets = entries.filter((e) => matchers.some((m) => m.test(e))).map((e) => join(tmpdir, e));
5477
+ const targets = entries.filter((e) => matchers.some((m) => m.test(e))).map((e) => join2(tmpdir, e));
4940
5478
  await trashPaths(targets);
4941
5479
  }
4942
5480
  async function nodeModulesPresent() {
4943
- return await fileExists6("node_modules/.package-lock.json") || await fileExists6("node_modules/.bin/expo") || await fileExists6("node_modules/expo/package.json");
5481
+ return await fileExists8("node_modules/.package-lock.json") || await fileExists8("node_modules/.bin/expo") || await fileExists8("node_modules/expo/package.json");
4944
5482
  }
4945
5483
  var STEP_TTL_HOURS = {
4946
5484
  accounts: 24,
@@ -4954,8 +5492,8 @@ var STEP_TTL_HOURS = {
4954
5492
  // EAS credentials don't drift; once configured, they stay until you rotate.
4955
5493
  "apple-credentials": Infinity,
4956
5494
  // ASC project link via `eas integrations:asc:connect`. Live-checked through
4957
- // `eas integrations:asc:status` so the cache TTL is short. drift would
4958
- // mean someone disconnected via the EAS dashboard.
5495
+ // `eas integrations:asc:status` so cache TTL is short. Drift would mean
5496
+ // someone disconnected via the EAS dashboard.
4959
5497
  "apple-asc-link": 24,
4960
5498
  // No cache for the rotation secrets phase. EAS env state is the source of
4961
5499
  // truth, and the secrets list query takes ~1s.
@@ -4976,6 +5514,9 @@ async function shouldRun(step, liveCheck) {
4976
5514
  return { step, label: step, status: "cached" };
4977
5515
  }
4978
5516
  const live = await liveCheck();
5517
+ if (live && !options.dryRun && !options.plan && !options.noState) {
5518
+ await recordStep(step, { source: "live-check" });
5519
+ }
4979
5520
  return { step, label: step, status: live ? "live" : "missing" };
4980
5521
  }
4981
5522
  async function liveCheckBetterAuth(env2) {
@@ -4995,7 +5536,7 @@ async function liveCheckApple(env2) {
4995
5536
  );
4996
5537
  }
4997
5538
  async function liveCheckEas() {
4998
- const projectId = await projectIdFromAppJson();
5539
+ const projectId = await resolveProjectId();
4999
5540
  if (!projectId) return false;
5000
5541
  const eas = await envList("production").catch(() => /* @__PURE__ */ new Map());
5001
5542
  return ["EXPO_PUBLIC_CONVEX_URL", "EXPO_PUBLIC_CONVEX_SITE_URL", "EXPO_PUBLIC_SITE_URL"].every(
@@ -5004,15 +5545,15 @@ async function liveCheckEas() {
5004
5545
  }
5005
5546
  async function liveCheckAscLink() {
5006
5547
  try {
5007
- const { ascStatus: ascStatus2 } = await import('./eas-integrations-TIYBWWKC.js');
5548
+ const { ascStatus: ascStatus2 } = await import('./eas-integrations-2QVR45NE.js');
5008
5549
  const status = await ascStatus2();
5009
- return Boolean(status.connected);
5550
+ return status.status === "connected";
5010
5551
  } catch {
5011
5552
  return false;
5012
5553
  }
5013
5554
  }
5014
5555
  async function liveCheckRotationSecrets() {
5015
- const projectId = await projectIdFromAppJson();
5556
+ const projectId = await resolveProjectId();
5016
5557
  if (!projectId) return false;
5017
5558
  const eas = await envList("production").catch(() => /* @__PURE__ */ new Map());
5018
5559
  return [
@@ -5044,11 +5585,11 @@ async function stepPrerequisites() {
5044
5585
  else yep("Xcode not detected (install from Mac App Store)");
5045
5586
  const [easV, convexV] = await Promise.all([version2(), version()]);
5046
5587
  if (easV) ok(`eas-cli ${easV}`);
5047
- else nop("eas-cli not on PATH (bunx will fetch on demand)");
5588
+ else nop("eas-cli not on PATH (npx will fetch on demand)");
5048
5589
  if (convexV) ok(`convex ${convexV}`);
5049
- else nop("convex CLI not on PATH (bunx will fetch on demand)");
5590
+ else nop("convex CLI not on PATH (npx will fetch on demand)");
5050
5591
  if (await isLoggedIn()) ok("Convex auth detected");
5051
- else yep("not signed in to Convex (`bunx vexpo accounts` will prompt)");
5592
+ else yep("not signed in to Convex (`npx vexpo accounts` will prompt)");
5052
5593
  }
5053
5594
  async function stepProbe() {
5054
5595
  section("Probe");
@@ -5087,7 +5628,7 @@ async function stepProbe() {
5087
5628
  line(` ${BOLD}${label.padEnd(w)}${RESET} ${mark(row.status)}`);
5088
5629
  }
5089
5630
  line(
5090
- ` ${BOLD}${"Review account".padEnd(w)}${RESET} ${DIM}unknown (run \`bunx vexpo review-account\` to seed)${RESET}`
5631
+ ` ${BOLD}${"Review account".padEnd(w)}${RESET} ${DIM}unknown (run \`npx vexpo review-account\` to seed)${RESET}`
5091
5632
  );
5092
5633
  const needs = /* @__PURE__ */ new Map();
5093
5634
  for (const [k, row] of rows) needs.set(k, row.status === "missing");
@@ -5195,7 +5736,7 @@ async function describePhase(step, probe) {
5195
5736
  details: [
5196
5737
  "validates an ASC API key against ASC's GET /v1/apps before EAS uses it",
5197
5738
  "we do NOT upload to EAS. that's `eas credentials`. We only validate + cache",
5198
- "cache (issuer, keyId, p8 path) in state.json so `bunx vexpo apple services-id` can reuse",
5739
+ "cache (issuer, keyId, p8 path) in state.json so `npx vexpo apple services-id` can reuse",
5199
5740
  "fast-fail: catches a bad key in <1s instead of waiting for an EAS build to fail"
5200
5741
  ]
5201
5742
  };
@@ -5239,23 +5780,24 @@ async function describePhase(step, probe) {
5239
5780
  return {
5240
5781
  step,
5241
5782
  label: "setup:asc:connect",
5242
- action: cached && !options.force ? "skip (already connected)" : "run (non-interactive)",
5783
+ action: cached && !options.force ? "skip (already connected)" : "run (eas-cli interactive)",
5243
5784
  details: [
5244
- "wraps `eas integrations:asc:connect` to link the EAS project to its ASC app",
5245
- "fills --api-key-id from cached asc-key state, --bundle-id from .env.local",
5246
- "after this, `eas submit` skips ASC app discovery (faster + non-interactive)"
5785
+ "spawns `eas integrations:asc:connect --bundle-id <bundle>`",
5786
+ "pre-sets EXPO_ASC_API_KEY_* env vars from cached asc-key state",
5787
+ "wizard prompts once when no key is uploaded yet (Create new / Use existing)",
5788
+ "after this, `eas submit` skips ASC app discovery"
5247
5789
  ]
5248
5790
  };
5249
5791
  case "apple-eas-rotation-secrets":
5250
5792
  return {
5251
5793
  step,
5252
5794
  label: "setup:apple:eas-rotation-secrets",
5253
- action: cached && !options.force ? "skip (all 5 set)" : "run (interactive for CONVEX_DEPLOY_KEY)",
5795
+ action: cached && !options.force ? "skip (all 5 set)" : "run (mints CONVEX_DEPLOY_KEY)",
5254
5796
  details: [
5255
5797
  "push the 5 EAS production secrets the JWT rotation cron needs",
5256
- "APPLE_P8_PRIVATE_KEY (PEM contents from cached path)",
5798
+ "APPLE_P8_PRIVATE_KEY (.p8 path; EAS reads + base64-encodes it)",
5257
5799
  "APPLE_TEAM_ID, APPLE_KEY_ID, APPLE_SERVICES_ID (from .env.local)",
5258
- "CONVEX_DEPLOY_KEY (prompted, generate at dashboard.convex.dev)"
5800
+ "CONVEX_DEPLOY_KEY (minted via the Convex Platform API; paste fallback if offline)"
5259
5801
  ]
5260
5802
  };
5261
5803
  }
@@ -5282,7 +5824,7 @@ async function printDryRunPlan(probe) {
5282
5824
  section("Dry run plan");
5283
5825
  if (options.fresh)
5284
5826
  note(
5285
- `${YELLOW}--fresh${RESET}: would wipe .setup-state.json + node_modules + ios/ + bun.lock + build artifacts before reprovisioning`
5827
+ `${YELLOW}--fresh${RESET}: would wipe .setup-state.json + node_modules + ios/ + package-lock.json + build artifacts before reprovisioning`
5286
5828
  );
5287
5829
  if (options.force) note(`${YELLOW}--force${RESET}: would re-run every step regardless of cache`);
5288
5830
  if (probe.install) note(`would run install (no node_modules)`);
@@ -5305,7 +5847,7 @@ async function printDryRunPlan(probe) {
5305
5847
  );
5306
5848
  line();
5307
5849
  note(`drop ${DIM}--dry-run${RESET} to actually do it`);
5308
- note(`single-phase: ${DIM}bunx vexpo <phase>${RESET} (e.g. ${DIM}bunx vexpo resend${RESET})`);
5850
+ note(`single-phase: ${DIM}npx vexpo <phase>${RESET} (e.g. ${DIM}npx vexpo resend${RESET})`);
5309
5851
  }
5310
5852
  var JOURNEY = {
5311
5853
  async: [
@@ -5348,8 +5890,8 @@ var JOURNEY = {
5348
5890
  },
5349
5891
  {
5350
5892
  label: "Convex production deploy key",
5351
- cost: "30 sec",
5352
- description: "For the JWT rotation cron and the deploy_convex step in deploy-production.yml. Generate once, paste into the CLI when prompted.",
5893
+ cost: "auto",
5894
+ description: "For the JWT rotation cron and the deploy_convex step in deploy-production.yml. Minted automatically via the Convex Platform API; paste only as an offline fallback.",
5353
5895
  url: "https://dashboard.convex.dev"
5354
5896
  },
5355
5897
  {
@@ -5397,7 +5939,7 @@ var JOURNEY = {
5397
5939
  {
5398
5940
  label: "EAS rotation secrets",
5399
5941
  cost: "auto",
5400
- description: "Pushes the 5 EAS production secrets the JWT rotation cron needs (4 from .env.local + state, plus CONVEX_DEPLOY_KEY which you paste)."
5942
+ description: "Pushes the 5 EAS production secrets the JWT rotation cron needs (4 from .env.local + state, plus CONVEX_DEPLOY_KEY minted via the Platform API)."
5401
5943
  }
5402
5944
  ]
5403
5945
  };
@@ -5405,7 +5947,7 @@ function printJourneyPlan(lite) {
5405
5947
  if (lite) {
5406
5948
  section("Setup journey (lite)");
5407
5949
  line(
5408
- ` ${DIM}Lite mode provisions only what the iOS Simulator needs. No Apple Developer account, no domain, no Resend, no EAS account. ~60 seconds from start to \`bun run ios\`.${RESET}`
5950
+ ` ${DIM}Lite mode provisions only what the iOS Simulator needs. No Apple Developer account, no domain, no Resend, no EAS account. ~60 seconds from start to \`npm run ios\`.${RESET}`
5409
5951
  );
5410
5952
  line();
5411
5953
  line(` ${BOLD}${GREEN}Auto${RESET} ${DIM}(CLI does it, no input needed)${RESET}`);
@@ -5464,7 +6006,19 @@ var LITE_AUTO_LABELS = /* @__PURE__ */ new Set([
5464
6006
  ]);
5465
6007
  function isComplete(result) {
5466
6008
  if (result.install) return false;
5467
- for (const required of ["convex", "better-auth", "resend", "apple-sign-in", "eas"]) {
6009
+ for (const required of [
6010
+ "rebrand",
6011
+ "convex",
6012
+ "better-auth",
6013
+ "resend",
6014
+ "asc-key",
6015
+ "apple-credentials",
6016
+ "apple-asc-link",
6017
+ "apple-services-id",
6018
+ "apple-sign-in",
6019
+ "apple-eas-rotation-secrets",
6020
+ "eas"
6021
+ ]) {
5468
6022
  if (result.needs.get(required)) return false;
5469
6023
  }
5470
6024
  return true;
@@ -5474,6 +6028,7 @@ async function stepCleanup(fresh) {
5474
6028
  const tmpdir = process.env.TMPDIR ?? "/tmp";
5475
6029
  const targets = [
5476
6030
  "node_modules",
6031
+ "package-lock.json",
5477
6032
  "bun.lock",
5478
6033
  "ios",
5479
6034
  ".expo",
@@ -5502,6 +6057,7 @@ async function stepInstallOnly() {
5502
6057
  }
5503
6058
  var completed = [];
5504
6059
  var skipped = [];
6060
+ var failedStep = null;
5505
6061
  var STEP_RUNNERS = {
5506
6062
  "vexpo accounts": () => runAccounts({ lite: options.lite }),
5507
6063
  "vexpo rebrand": () => runRebrand({}),
@@ -5514,14 +6070,19 @@ var STEP_RUNNERS = {
5514
6070
  "vexpo apple jwt": () => runAppleJwt({}),
5515
6071
  "vexpo apple eas-rotation-secrets": () => runEasRotationSecrets({}),
5516
6072
  "vexpo asc connect": () => runAscConnect({}),
5517
- "vexpo eas": () => runEas({}),
6073
+ "vexpo eas": async () => runEas({ withProd: await fileExists8(".env.prod") || await fileExists8(".env.production") }),
5518
6074
  "vexpo review-account": () => runReviewAccount({})
5519
6075
  };
5520
6076
  async function runStep(name, state) {
5521
6077
  const runner = STEP_RUNNERS[name];
5522
6078
  if (!runner) throw new Error(`unknown setup step: ${name}`);
5523
- const code = await runner();
5524
- if (code !== 0) throw new Error(`${name} exited with code ${code}`);
6079
+ try {
6080
+ const code = await runner();
6081
+ if (code !== 0) throw new Error(`${name} exited with code ${code}`);
6082
+ } catch (err) {
6083
+ if (state) failedStep = state;
6084
+ throw err;
6085
+ }
5525
6086
  if (state) completed.push(state);
5526
6087
  }
5527
6088
  async function maybeRunStep(name, prompt, state) {
@@ -5543,7 +6104,7 @@ function printShipNextSteps() {
5543
6104
  line(` Run this when you're ready:`);
5544
6105
  line();
5545
6106
  line(
5546
- ` ${BOLD}bunx eas build -p ios --profile production --auto-submit-with-profile testflight${RESET}`
6107
+ ` ${BOLD}npx eas build -p ios --profile production --auto-submit-with-profile testflight${RESET}`
5547
6108
  );
5548
6109
  line();
5549
6110
  line(
@@ -5563,7 +6124,7 @@ async function printSummary(useLocal, elapsedMs) {
5563
6124
  section("Summary");
5564
6125
  const [localEnv, convexEnv] = await Promise.all([readAll(), envMap()]);
5565
6126
  const easEnv = await envList("production").catch(() => /* @__PURE__ */ new Map());
5566
- const projectId = await projectIdFromAppJson();
6127
+ const projectId = await resolveProjectId();
5567
6128
  const state = await load();
5568
6129
  const localKeys = [
5569
6130
  "CONVEX_DEPLOYMENT",
@@ -5601,9 +6162,13 @@ async function printSummary(useLocal, elapsedMs) {
5601
6162
  "convex",
5602
6163
  "better-auth",
5603
6164
  "resend",
6165
+ "review-account",
5604
6166
  "asc-key",
6167
+ "apple-credentials",
6168
+ "apple-asc-link",
5605
6169
  "apple-services-id",
5606
6170
  "apple-sign-in",
6171
+ "apple-eas-rotation-secrets",
5607
6172
  "eas"
5608
6173
  ];
5609
6174
  const width = Math.max(
@@ -5639,16 +6204,18 @@ async function printSummary(useLocal, elapsedMs) {
5639
6204
  ${GREEN}ok${RESET} setup complete in ${(elapsedMs / 1e3).toFixed(2)}s`);
5640
6205
  line(
5641
6206
  `
5642
- next: ${BOLD}${useLocal ? "bunx convex dev --local" : "bun run convex:dev"}${RESET} ${DIM}then${RESET} ${BOLD}bun run ios${RESET}
6207
+ next: ${BOLD}${useLocal ? "npx convex dev" : "npm run convex:dev"}${RESET} ${DIM}then${RESET} ${BOLD}npm run ios${RESET}
5643
6208
  `
5644
6209
  );
5645
6210
  }
5646
6211
  async function runSetup(opts) {
5647
6212
  options = opts;
6213
+ failedStep = null;
6214
+ completed.length = 0;
6215
+ skipped.length = 0;
5648
6216
  const startedAtPerf = performance.now();
5649
6217
  const startedAtIso = (/* @__PURE__ */ new Date()).toISOString();
5650
6218
  let failureMessage = null;
5651
- let failureStep = null;
5652
6219
  try {
5653
6220
  if (options.fresh) {
5654
6221
  await clearAll();
@@ -5656,7 +6223,7 @@ async function runSetup(opts) {
5656
6223
  if (!options.dryRun && !options.plan && !options.noState) {
5657
6224
  const existing = await load();
5658
6225
  const concurrent = checkConcurrentRun(existing);
5659
- if (concurrent.stale) {
6226
+ if (concurrent.active && concurrent.otherPid !== void 0) {
5660
6227
  yep(
5661
6228
  `another vexpo run (pid ${concurrent.otherPid}) touched .setup-state.json recently; if you're not running in another terminal, ignore this`
5662
6229
  );
@@ -5844,7 +6411,7 @@ async function runSetup(opts) {
5844
6411
  cwd: process.cwd(),
5845
6412
  completed,
5846
6413
  skipped,
5847
- ...failureMessage ? { failed: { step: failureStep ?? "convex", message: failureMessage } } : {}
6414
+ ...failureMessage ? { failed: { step: failedStep ?? "convex", message: failureMessage } } : {}
5848
6415
  });
5849
6416
  } catch {
5850
6417
  }
@@ -5913,13 +6480,29 @@ program.command("doctor").description(
5913
6480
  ).option("--channel <channel>", "dev | prod", "dev").option("--json", "machine-readable output", false).option("--strict", "exit non-zero on any warn", false).action((options2) => {
5914
6481
  exitWith(runDoctor(options2));
5915
6482
  });
6483
+ program.command("adopt").description(
6484
+ "Finish a project created by `eas integrations:convex:connect`: adopt the existing dev deployment (never a fresh one), backfill site URLs + Better Auth, report the deployment topology (flagging a duplicate dev deployment), and print the exact commands left to finish."
6485
+ ).option("--skip-dev-steps", "report topology + runbook only, don't run convex/better-auth", false).action((options2) => exitWith(runAdopt(options2)));
5916
6486
  program.command("convex").description("Provision or connect a Convex deployment.").option("--fresh", "provision a NEW deployment", false).option("--local", "self-hosted / local backend", false).option("--name <name>", "override Convex project name").action(
5917
6487
  (options2) => exitWith(runConvex(options2))
5918
6488
  );
6489
+ program.command("convex:migrate").description(
6490
+ "Copy server-side Convex env (BETTER_AUTH_SECRET, RESEND_*, APPLE_*, APP_*, ...) from another deployment onto the current one. The piece a deployment migration can't get off disk; CONVEX_* are left untouched."
6491
+ ).requiredOption("--from <deployment>", "source deployment slug to copy env from").option("--prod", "target the prod deployment (reads prod creds from .env.prod)").option("--dry-run", "show what would be copied, exit without changes", false).action(
6492
+ (options2) => exitWith(runConvexMigrate(options2))
6493
+ );
5919
6494
  program.command("better-auth").description("Set SITE_URL, BETTER_AUTH_SECRET, APP_NAME on Convex.").option("--rotate-secret", "regenerate BETTER_AUTH_SECRET", false).option("--site-url <url>", "override SITE_URL").option("--app-name <name>", "override APP_NAME").action(
5920
6495
  (options2) => exitWith(runBetterAuth(options2))
5921
6496
  );
5922
- program.command("resend").description("Provision Resend sending key + webhook, write to Convex env.").option("--name <name>", "override sending key name").option("--from <address>", "override EMAIL_FROM").action((options2) => exitWith(runResend(options2)));
6497
+ program.command("resend").description("Provision Resend sending key + webhook, write to Convex env.").option("--name <name>", "override sending key name").option("--from <address>", "override EMAIL_FROM").option(
6498
+ "--repoint",
6499
+ "move the webhook to the current convex.site + realign the secret, without rotating the sending key or changing auth policy"
6500
+ ).option("--prod", "with --repoint, target the prod deployment + .env.prod site URL").option(
6501
+ "--force",
6502
+ "with --repoint, recreate the webhook even if it already points at the endpoint"
6503
+ ).action(
6504
+ (options2) => exitWith(runResend(options2))
6505
+ );
5923
6506
  var apple = program.command("apple").description("Apple-side provisioning.");
5924
6507
  apple.command("asc-key").description("Validate an App Store Connect API key against `/v1/apps`. No eas-cli equivalent.").option("--revalidate", "re-check the cached key still works", false).action((options2) => exitWith(runAscKey(options2)));
5925
6508
  apple.command("services-id").description(
@@ -5927,7 +6510,10 @@ apple.command("services-id").description(
5927
6510
  ).option("--services-id <id>", "override Services ID").action((options2) => exitWith(runServicesId(options2)));
5928
6511
  apple.command("jwt").description(
5929
6512
  "Sign the Sign In with Apple ES256 client_secret JWT (180-day expiry, Apple's max). Quarterly auto-rotation runs as an EAS Workflow cron. No eas-cli equivalent."
5930
- ).option("--rotate", "re-sign the JWT only", false).action((options2) => exitWith(runAppleJwt(options2)));
6513
+ ).option("--rotate", "re-sign the JWT only", false).option(
6514
+ "--copy-from <deployment>",
6515
+ "copy APPLE_* env from another deployment (slug) instead of signing; no .p8 needed"
6516
+ ).action((options2) => exitWith(runAppleJwt(options2)));
5931
6517
  apple.command("credentials").description(
5932
6518
  "Provision iOS build credentials by wrapping `eas credentials:configure-build` with the cached ASC API key passed via env vars (skips the Apple Developer login prompt in the wizard). EAS generates the dist cert + provisioning profile + push key."
5933
6519
  ).option("-e, --profile <name>", "build profile", "production").action((options2) => exitWith(runAppleCredentials(options2)));
@@ -5951,37 +6537,35 @@ env.command("push").description(
5951
6537
  );
5952
6538
  }
5953
6539
  );
5954
- var ascVersion = program.command("asc:version").description("App Store version inspection.");
5955
- ascVersion.command("list").description("List App Store versions.").option("-p, --platform <p>", "IOS | MAC_OS | TV_OS | VISION_OS").option("--state <state>", "filter by state (e.g. IN_REVIEW, READY_FOR_SALE)").option("--limit <n>", "max items", (v) => parseInt(v, 10), 25).option("--json", "JSON output", false).action((options2) => exitWith(runVersionList(options2)));
5956
- ascVersion.command("view <versionId>").description("View a single App Store version + phased-release state.").option("--json", "JSON output", false).action(
5957
- (versionId, options2) => exitWith(runVersionView(versionId, options2))
5958
- );
5959
- ascVersion.command("phased <versionId> <action>").description("Pause | resume | complete the phased release for a version.").action((versionId, action) => {
5960
- if (!["pause", "resume", "complete"].includes(action)) {
5961
- process.stderr.write(`unknown action '${action}' (pause|resume|complete)
5962
- `);
5963
- process.exit(2);
5964
- }
5965
- exitWith(
5966
- runPhasedRelease({
5967
- versionId,
5968
- action
6540
+ env.command("convex-key").description(
6541
+ "Sync the Convex deploy key + deployment selector to EAS env (dev \u2192 development, prod \u2192 production/preview). Fixes a stale EAS deploy key after a deployment migration; env push skips these on purpose."
6542
+ ).option("--dev-key <key>", "dev deploy key (default: CONVEX_DEPLOY_KEY in .env.local)").option("--prod-key <key>", "prod deploy key (default: CONVEX_DEPLOY_KEY in .env.prod)").option("--mint", "mint the prod deploy key via the Platform API if EAS lacks one", false).option("--local-file <path>", "override .env.local path").option("--prod-file <path>", "override .env.prod path").action(
6543
+ (options2) => exitWith(
6544
+ runConvexKey({
6545
+ devKey: options2.devKey,
6546
+ prodKey: options2.prodKey,
6547
+ mint: options2.mint,
6548
+ localFile: options2.localFile,
6549
+ prodFile: options2.prodFile
5969
6550
  })
5970
- );
5971
- });
5972
- program.command("asc:submissions").description("List App Store review submissions for the current app.").option("-p, --platform <p>", "IOS | MAC_OS | TV_OS | VISION_OS").option("--state <state>", "filter by state").option("--json", "JSON output", false).action((options2) => exitWith(runSubmissionsList(options2)));
6551
+ )
6552
+ );
6553
+ program.command("asc:connect").description(
6554
+ "Link the EAS project to its App Store Connect app (wraps `eas integrations:asc:connect` with the cached ASC key). Lets `eas submit` resolve the app from the bundle id, so eas.json needs no committed ascAppId. Needs an interactive terminal."
6555
+ ).option("--force", "re-run even if already connected", false).action((options2) => exitWith(runAscConnect(options2)));
6556
+ var ascPrivacy = program.command("asc:privacy").description("Privacy nutrition labels (local).");
6557
+ ascPrivacy.command("show [file]").description("Show the declared privacy.config.json (Apple has no live read API; set it in ASC).").option("--json", "JSON output", false).action(
6558
+ (file, options2) => exitWith(runPrivacyShow(file ?? "app-store/privacy.config.json", options2))
6559
+ );
6560
+ ascPrivacy.command("lint <file>").description("Validate a local privacy.config.json against Apple's enums.").action((file) => exitWith(runPrivacyLint(file)));
6561
+ var ascA11y = program.command("asc:accessibility").description("Accessibility nutrition labels (iOS 26+).");
6562
+ ascA11y.command("show").description("Fetch the app's current accessibility declarations.").option("--json", "JSON output", false).action((options2) => exitWith(runAccessibilityShow(options2)));
6563
+ ascA11y.command("lint <file>").description("Validate a local accessibility.config.json against Apple's enums.").action((file) => exitWith(runAccessibilityLint(file)));
5973
6564
  var testflight2 = program.command("testflight").description("TestFlight beta groups + testers via ASC API.");
5974
6565
  var tfGroups = testflight2.command("groups").description("Beta groups.");
5975
6566
  tfGroups.command("list").description("List beta groups for the current app.").option("--json", "JSON output", false).action((options2) => exitWith(runTestflightGroupsList(options2)));
5976
- tfGroups.command("create <name>").description("Create a beta group.").option("--public-link", "enable public link", false).option("--public-limit <n>", "public link tester limit", (v) => parseInt(v, 10)).option("--feedback", "enable in-app feedback", false).action(
5977
- (name, options2) => exitWith(
5978
- runTestflightGroupsCreate({
5979
- name,
5980
- publicLink: options2.publicLink,
5981
- publicLimit: options2.publicLimit,
5982
- feedback: options2.feedback
5983
- })
5984
- )
6567
+ tfGroups.command("create <name>").description("Create a beta group.").option("--feedback", "enable in-app feedback", false).action(
6568
+ (name, options2) => exitWith(runTestflightGroupsCreate({ name, feedback: options2.feedback }))
5985
6569
  );
5986
6570
  tfGroups.command("view <id>").description("View a beta group + its testers.").option("--json", "JSON output", false).action(
5987
6571
  (id, options2) => exitWith(runTestflightGroupsView(id, options2))
@@ -5999,19 +6583,7 @@ testflight2.command("invite <email>").description("Add a tester + send a TestFli
5999
6583
  })
6000
6584
  )
6001
6585
  );
6002
- testflight2.command("remove <email>").description("Remove a beta tester.").action((email) => exitWith(runTestflightRemove(email)));
6003
6586
  testflight2.command("whats-new <buildId> <text>").description(`Set the "What's new" release notes for a TestFlight build.`).option("--locale <locale>", "ISO locale", "en-US").action(
6004
6587
  (buildId, text, options2) => exitWith(runTestflightWhatsNew({ buildId, locale: options2.locale ?? "en-US", text }))
6005
6588
  );
6006
- var reviewsCmd = program.command("reviews").description("Customer reviews + responses via ASC API.");
6007
- reviewsCmd.command("list").description("List customer reviews.").option("--territory <code>", "filter by territory (e.g. US)").option("--rating <n>", "filter by rating (1-5)", (v) => parseInt(v, 10)).option("--limit <n>", "max items", (v) => parseInt(v, 10), 50).option("--json", "JSON output", false).action((options2) => exitWith(runReviewsList(options2)));
6008
- reviewsCmd.command("unanswered").description("List reviews without a response.").option("--days <n>", "older than N days", (v) => parseInt(v, 10)).option("--limit <n>", "max items to scan", (v) => parseInt(v, 10), 200).option("--json", "JSON output", false).action((options2) => exitWith(runReviewsUnanswered(options2)));
6009
- reviewsCmd.command("respond <reviewId> <body>").description("Post a response to a customer review.").action((reviewId, body) => exitWith(runReviewsRespond({ reviewId, body })));
6010
- reviewsCmd.command("delete-response <responseId>").description("Delete a review response.").action((responseId) => exitWith(runReviewsDeleteResponse(responseId)));
6011
- var sandboxCmd = program.command("sandbox").description("Sandbox testers for IAP testing via ASC API.");
6012
- sandboxCmd.command("list").description("List sandbox testers.").option("--json", "JSON output", false).action((options2) => exitWith(runSandboxList(options2)));
6013
- sandboxCmd.command("create").description("Create a sandbox tester.").requiredOption("--email <email>").requiredOption("--password <password>", "8+ chars; this is the App Store sandbox password").requiredOption("--first-name <name>").requiredOption("--last-name <name>").requiredOption("--territory <code>", "ISO territory code (e.g. USA)").action(
6014
- (options2) => exitWith(runSandboxCreate(options2))
6015
- );
6016
- sandboxCmd.command("delete <id>").description("Delete a sandbox tester.").action((id) => exitWith(runSandboxDelete(id)));
6017
6589
  program.parse();