@gonvex/cli 0.1.9 → 0.1.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -2,14 +2,36 @@
2
2
  import { createHash } from "node:crypto";
3
3
  import { spawn } from "node:child_process";
4
4
  import { existsSync, readFileSync, realpathSync } from "node:fs";
5
- import { mkdir, readFile, readdir, stat, writeFile, copyFile } from "node:fs/promises";
5
+ import { chmod, mkdir, readFile, readdir, rm, stat, writeFile, copyFile } from "node:fs/promises";
6
+ import { homedir } from "node:os";
6
7
  import { createInterface } from "node:readline/promises";
7
8
  import { dirname, join, relative, resolve } from "node:path";
9
+ import { Writable } from "node:stream";
8
10
  import { fileURLToPath } from "node:url";
9
11
  const defaultRuntimeURL = "http://localhost:8080";
10
12
  const runtimeSyncRetryMs = 5000;
11
13
  const runtimeStateCheckMs = 2500;
12
14
  const supportsColor = process.env.NO_COLOR === undefined && (process.env.FORCE_COLOR !== undefined || process.stdout.isTTY);
15
+ const defaultAccountTokenPermissions = ["projects:read", "projects:create", "projects:keys:read"];
16
+ const accountTokenPermissions = [
17
+ "projects:read",
18
+ "projects:create",
19
+ "projects:update",
20
+ "projects:delete",
21
+ "projects:keys:read",
22
+ "projects:keys:write",
23
+ "projects:members:read",
24
+ "projects:members:write",
25
+ "projects:env:read",
26
+ "projects:env:write",
27
+ "projects:*",
28
+ "admin:projects",
29
+ "tokens:read",
30
+ "tokens:create",
31
+ "tokens:revoke",
32
+ "tokens:*",
33
+ "*",
34
+ ];
13
35
  function ansi(code, value) {
14
36
  return supportsColor ? `\x1b[${code}m${value}\x1b[0m` : value;
15
37
  }
@@ -42,20 +64,110 @@ export async function main(argv = process.argv.slice(2)) {
42
64
  await runEnv(argv.slice(1));
43
65
  return;
44
66
  }
67
+ if (command === "auth") {
68
+ await runAuth(argv.slice(1));
69
+ return;
70
+ }
71
+ if (command === "login") {
72
+ await runLogin(argv.slice(1));
73
+ return;
74
+ }
75
+ if (command === "logout") {
76
+ await runLogout(argv.slice(1));
77
+ return;
78
+ }
79
+ if (command === "whoami") {
80
+ await runWhoAmI(argv.slice(1));
81
+ return;
82
+ }
83
+ if (command === "token" || command === "tokens") {
84
+ await runToken(argv.slice(1));
85
+ return;
86
+ }
87
+ if (command === "project") {
88
+ await runProject(argv.slice(1));
89
+ return;
90
+ }
45
91
  printHelp();
46
92
  throw new Error(`unknown command ${command}`);
47
93
  }
48
94
  export async function runCreate(argv) {
49
- const target = argv.find((arg) => !arg.startsWith("-")) ?? "my-gonvex-app";
95
+ const optionNames = ["--template", "--runtime-url", "--runtime", "--database-mode", "--origin", "--callback-path", "--signup-mode", "--owner"];
96
+ const target = positionalArgs(argv, optionNames)[0] ?? "my-gonvex-app";
50
97
  const appName = basename(target);
51
98
  const template = valueFor(argv, "--template") ?? "vite-react";
99
+ const runtimeURL = (valueFor(argv, "--runtime-url") ?? valueFor(argv, "--runtime") ?? defaultRuntimeURL).replace(/\/$/, "");
100
+ const databaseMode = valueFor(argv, "--database-mode") ?? "single";
101
+ if (databaseMode !== "single" && databaseMode !== "multiTenant")
102
+ throw new Error("--database-mode must be single or multiTenant");
103
+ const googleAuth = argv.includes("--google-auth");
104
+ const signupMode = normalizeAuthSignupMode(valueFor(argv, "--signup-mode") ?? "personal");
105
+ const ownerEmail = valueFor(argv, "--owner")?.trim() ?? "";
106
+ if (googleAuth && signupMode === "inviteOnly" && !ownerEmail) {
107
+ throw new Error("--owner <verified-google-email> is required when creating an invite-only Google app");
108
+ }
109
+ const shouldProvision = googleAuth || argv.includes("--provision") || Boolean(valueFor(argv, "--runtime-url") ?? valueFor(argv, "--runtime"));
52
110
  const root = resolve(target);
53
111
  if (existsSync(root))
54
112
  throw new Error(`${target} already exists`);
55
- await copyTemplate(template, root);
56
- await rewritePackageName(root, appName);
57
- await rewriteGonvexConfig(root, appName, defaultRuntimeURL);
58
- await writeEnvLocal(root, appName, defaultRuntimeURL);
113
+ let created = null;
114
+ let provisioningAccountToken;
115
+ try {
116
+ await copyTemplate(template, root);
117
+ await rewritePackageName(root, appName);
118
+ await rewriteGonvexConfig(root, appName, runtimeURL);
119
+ if (shouldProvision) {
120
+ provisioningAccountToken = await accountAccessTokenForRuntime(runtimeURL);
121
+ created = await createRuntimeProject(runtimeURL, appName, provisioningAccountToken, databaseMode);
122
+ await rewriteGonvexConfig(root, created.project.id, runtimeURL);
123
+ await writeProjectEnv(root, runtimeURL, created.project.id, created.projectKey, true);
124
+ if (googleAuth) {
125
+ const authArgs = ["add", "google", "--project", root, "--signup-mode", signupMode];
126
+ const callbackPath = valueFor(argv, "--callback-path");
127
+ if (callbackPath)
128
+ authArgs.push("--callback-path", callbackPath);
129
+ const suppliedOrigins = valuesFor(argv, "--origin");
130
+ const origins = suppliedOrigins.length > 0 ? suppliedOrigins : [
131
+ process.env.GONVEX_APP_ORIGIN ?? process.env.VITE_APP_URL ?? "http://localhost:5173",
132
+ ];
133
+ for (const origin of origins)
134
+ authArgs.push("--origin", origin);
135
+ await runAuth(authArgs);
136
+ if (signupMode === "inviteOnly") {
137
+ if (databaseMode === "multiTenant") {
138
+ await runAuth(["tenants", "create", `${appName} workspace`, "--owner", ownerEmail, "--project", root]);
139
+ }
140
+ else {
141
+ await runAuth(["memberships", "add", "--tenant", created.project.id, "--email", ownerEmail, "--role", "owner", "--project", root]);
142
+ }
143
+ }
144
+ const hasProductionOrigin = origins.some((origin) => normalizeAppOrigin(origin).startsWith("https://"));
145
+ if (hasProductionOrigin && !argv.includes("--allow-unready")) {
146
+ await runAuth(["doctor", "--project", root]);
147
+ }
148
+ }
149
+ }
150
+ else {
151
+ await writeEnvLocal(root, appName, runtimeURL);
152
+ }
153
+ }
154
+ catch (error) {
155
+ if (created) {
156
+ try {
157
+ await runtimeJSON(await fetch(`${runtimeURL}/dev/projects/${encodeURIComponent(created.project.id)}`, {
158
+ method: "DELETE",
159
+ headers: provisioningAccountToken
160
+ ? accountHeaders(provisioningAccountToken)
161
+ : projectAuthHeaders({ runtimeURL, projectID: created.project.id, key: created.projectKey }),
162
+ }));
163
+ }
164
+ catch (cleanupError) {
165
+ console.warn(`[gonvex] could not roll back runtime project ${created.project.id}: ${cleanupError instanceof Error ? cleanupError.message : String(cleanupError)}`);
166
+ }
167
+ }
168
+ await rm(root, { recursive: true, force: true });
169
+ throw error;
170
+ }
59
171
  console.log(`[gonvex] created ${target} from ${template} template`);
60
172
  console.log(`[gonvex] next: cd ${target} && npm install && npm run dev`);
61
173
  }
@@ -67,6 +179,205 @@ async function runInit(argv) {
67
179
  await writeEnvLocal(process.cwd(), project, runtime);
68
180
  console.log(`[gonvex] initialized ${project}`);
69
181
  }
182
+ async function runLogin(argv) {
183
+ const runtimeURL = await accountRuntimeForArgs(argv);
184
+ const suppliedToken = valueFor(argv, "--token") ?? process.env.GONVEX_ACCOUNT_TOKEN;
185
+ let accessToken;
186
+ let expiresAt;
187
+ if (suppliedToken) {
188
+ accessToken = suppliedToken.trim();
189
+ if (!accessToken)
190
+ throw new Error("--token requires a non-empty personal access token");
191
+ }
192
+ else {
193
+ const positional = positionalArgs(argv, ["--runtime-url", "--runtime", "--email", "--password", "--token"]);
194
+ let email = valueFor(argv, "--email") ?? positional[0] ?? "";
195
+ let password = valueFor(argv, "--password") ?? process.env.GONVEX_PASSWORD ?? "";
196
+ if ((!email || !password) && (!process.stdin.isTTY || !process.stdout.isTTY)) {
197
+ throw new Error("non-interactive login requires --email and --password (or GONVEX_PASSWORD), or --token");
198
+ }
199
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
200
+ try {
201
+ if (!email)
202
+ email = (await rl.question("Email: ")).trim();
203
+ }
204
+ finally {
205
+ rl.close();
206
+ }
207
+ if (!password)
208
+ password = await promptHidden("Password: ");
209
+ if (!email || !password)
210
+ throw new Error("email and password are required");
211
+ const response = await fetch(`${runtimeURL}/dev/auth/login`, {
212
+ method: "POST",
213
+ headers: { "content-type": "application/json" },
214
+ body: JSON.stringify({ email, password }),
215
+ });
216
+ const payload = await runtimeJSON(response);
217
+ accessToken = payload.session?.accessToken ?? "";
218
+ expiresAt = payload.session?.expiresAt;
219
+ if (!accessToken)
220
+ throw new Error("runtime login did not return an account session");
221
+ }
222
+ const identity = await fetchAccountIdentity(runtimeURL, accessToken);
223
+ await saveAccountProfile(runtimeURL, accessToken, identity, expiresAt);
224
+ console.log(`[gonvex] logged in as ${identity.account.email} to ${runtimeURL}`);
225
+ }
226
+ async function runLogout(argv) {
227
+ const runtimeURL = await accountRuntimeForArgs(argv);
228
+ const removed = await removeAccountProfile(runtimeURL);
229
+ if (removed)
230
+ console.log(`[gonvex] logged out of ${runtimeURL}`);
231
+ else
232
+ console.log(`[gonvex] no saved login for ${runtimeURL}`);
233
+ }
234
+ async function runWhoAmI(argv) {
235
+ const runtimeURL = await accountRuntimeForArgs(argv);
236
+ const accessToken = await requireAccountAccessToken(runtimeURL);
237
+ const identity = await fetchAccountIdentity(runtimeURL, accessToken);
238
+ if (argv.includes("--json")) {
239
+ console.log(JSON.stringify({ runtimeURL, ...identity }, null, 2));
240
+ return;
241
+ }
242
+ console.log(`${identity.account.email} (${identity.account.role})`);
243
+ console.log(`Runtime: ${runtimeURL}`);
244
+ console.log(`Authentication: ${identity.authentication}`);
245
+ console.log(`Permissions: ${identity.permissions.join(", ")}`);
246
+ }
247
+ async function runToken(argv) {
248
+ const commandArgs = positionalArgs(argv, ["--runtime-url", "--runtime", "--permission", "--expires-at"]);
249
+ const action = commandArgs[0];
250
+ if (!action || action === "help" || action === "--help") {
251
+ printTokenHelp();
252
+ return;
253
+ }
254
+ if (action === "permissions") {
255
+ for (const permission of accountTokenPermissions)
256
+ console.log(permission);
257
+ return;
258
+ }
259
+ const runtimeURL = await accountRuntimeForArgs(argv);
260
+ const accessToken = await requireAccountAccessToken(runtimeURL);
261
+ if (action === "list" || action === "ls") {
262
+ const response = await fetch(`${runtimeURL}/dev/auth/tokens`, { headers: accountHeaders(accessToken) });
263
+ const payload = await runtimeJSON(response);
264
+ const tokens = payload.tokens ?? [];
265
+ if (argv.includes("--json")) {
266
+ console.log(JSON.stringify(tokens, null, 2));
267
+ return;
268
+ }
269
+ if (tokens.length === 0) {
270
+ console.log("[gonvex] no account tokens");
271
+ return;
272
+ }
273
+ for (const token of tokens) {
274
+ const state = token.revokedAt ? "revoked" : token.expiresAt && Date.parse(token.expiresAt) <= Date.now() ? "expired" : "active";
275
+ console.log(`${token.id}\t${token.name}\t${state}\t${token.permissions.join(",")}`);
276
+ }
277
+ return;
278
+ }
279
+ if (action === "create") {
280
+ const positional = positionalArgs(argv, ["--runtime-url", "--runtime", "--permission", "--expires-at"]);
281
+ const name = positional[1] ?? "CLI provisioning";
282
+ let permissions = valuesFor(argv, "--permission").flatMap((value) => value.split(",")).map((value) => value.trim()).filter(Boolean);
283
+ if (argv.includes("--full"))
284
+ permissions = ["*"];
285
+ if (permissions.length === 0)
286
+ permissions = defaultAccountTokenPermissions;
287
+ const unknown = permissions.find((permission) => !accountTokenPermissions.includes(permission));
288
+ if (unknown)
289
+ throw new Error(`unknown account token permission ${unknown}; run 'gonvex token permissions' to list valid values`);
290
+ const response = await fetch(`${runtimeURL}/dev/auth/tokens`, {
291
+ method: "POST",
292
+ headers: { ...accountHeaders(accessToken), "content-type": "application/json" },
293
+ body: JSON.stringify({ name, permissions, expiresAt: valueFor(argv, "--expires-at") }),
294
+ });
295
+ const payload = await runtimeJSON(response);
296
+ if (argv.includes("--json")) {
297
+ console.log(JSON.stringify(payload, null, 2));
298
+ return;
299
+ }
300
+ console.error(`[gonvex] created account token ${payload.token.id}; copy it now because it will not be shown again:`);
301
+ console.log(payload.accessToken);
302
+ return;
303
+ }
304
+ if (action === "revoke" || action === "delete" || action === "rm") {
305
+ const positional = positionalArgs(argv, ["--runtime-url", "--runtime"]);
306
+ const id = positional[1];
307
+ if (!id)
308
+ throw new Error("usage: gonvex token revoke <token-id>");
309
+ const response = await fetch(`${runtimeURL}/dev/auth/tokens/${encodeURIComponent(id)}`, {
310
+ method: "DELETE",
311
+ headers: accountHeaders(accessToken),
312
+ });
313
+ await runtimeJSON(response);
314
+ console.log(`[gonvex] revoked account token ${id}`);
315
+ return;
316
+ }
317
+ printTokenHelp();
318
+ throw new Error(`unknown token command ${action}`);
319
+ }
320
+ async function runProject(argv) {
321
+ const commandArgs = positionalArgs(argv, ["--runtime-url", "--runtime", "--database-mode", "--project-root"]);
322
+ const action = commandArgs[0];
323
+ if (!action || action === "help" || action === "--help") {
324
+ printProjectHelp();
325
+ return;
326
+ }
327
+ const runtimeURL = await accountRuntimeForArgs(argv);
328
+ const accountToken = await accountAccessTokenForRuntime(runtimeURL);
329
+ if (action === "list" || action === "ls") {
330
+ const projects = await fetchRuntimeProjects(runtimeURL, accountToken);
331
+ if (argv.includes("--json")) {
332
+ console.log(JSON.stringify(projects, null, 2));
333
+ return;
334
+ }
335
+ if (projects.length === 0) {
336
+ console.log("[gonvex] no projects");
337
+ return;
338
+ }
339
+ for (const project of projects)
340
+ console.log(`${project.id}\t${project.name}\t${project.environment ?? ""}\t${project.database ?? ""}`);
341
+ return;
342
+ }
343
+ if (action === "create") {
344
+ const positional = positionalArgs(argv, ["--runtime-url", "--runtime", "--database-mode", "--project-root"]);
345
+ const projectRoot = resolve(valueFor(argv, "--project-root") ?? ".");
346
+ if (!existsSync(projectRoot))
347
+ throw new Error(`project root ${projectRoot} does not exist`);
348
+ const name = positional[1] ?? basename(projectRoot);
349
+ const databaseMode = valueFor(argv, "--database-mode") ?? "single";
350
+ if (databaseMode !== "single" && databaseMode !== "multiTenant")
351
+ throw new Error("--database-mode must be single or multiTenant");
352
+ const created = await createRuntimeProject(runtimeURL, name, accountToken, databaseMode);
353
+ await writeProjectEnv(projectRoot, runtimeURL, created.project.id, created.projectKey, true);
354
+ if (argv.includes("--json")) {
355
+ console.log(JSON.stringify(created, null, 2));
356
+ return;
357
+ }
358
+ console.log(`[gonvex] created and configured ${created.project.name} (${created.project.id})`);
359
+ return;
360
+ }
361
+ if (action === "select" || action === "link") {
362
+ const positional = positionalArgs(argv, ["--runtime-url", "--runtime", "--project-root"]);
363
+ const id = positional[1];
364
+ if (!id)
365
+ throw new Error("usage: gonvex project select <project-id>");
366
+ const projectRoot = resolve(valueFor(argv, "--project-root") ?? ".");
367
+ if (!existsSync(projectRoot))
368
+ throw new Error(`project root ${projectRoot} does not exist`);
369
+ const projects = await fetchRuntimeProjects(runtimeURL, accountToken);
370
+ const project = projects.find((candidate) => candidate.id === id);
371
+ if (!project)
372
+ throw new Error(`project ${id} was not found or is not accessible`);
373
+ const projectKey = await fetchRuntimeProjectKey(runtimeURL, id, accountToken);
374
+ await writeProjectEnv(projectRoot, runtimeURL, id, projectKey, true);
375
+ console.log(`[gonvex] configured ${project.name} (${id})`);
376
+ return;
377
+ }
378
+ printProjectHelp();
379
+ throw new Error(`unknown project command ${action}`);
380
+ }
70
381
  async function runDev(argv) {
71
382
  const split = argv.indexOf("--");
72
383
  const flagArgs = split === -1 ? argv : argv.slice(0, split);
@@ -202,6 +513,373 @@ async function runEnv(argv) {
202
513
  printEnvHelp();
203
514
  throw new Error(`unknown env command ${action}`);
204
515
  }
516
+ export async function runAuth(argv) {
517
+ const optionsWithValues = ["--project", "--runtime-url", "--project-id", "--key", "--origin", "--callback-path", "--signup-mode", "--tenant", "--email", "--owner", "--role", "--user"];
518
+ const positional = positionalArgs(argv, optionsWithValues);
519
+ const action = positional[0];
520
+ if (!action || action === "help") {
521
+ printAuthHelp();
522
+ return;
523
+ }
524
+ const projectRoot = resolve(valueFor(argv, "--project") ?? ".");
525
+ const settings = await loadSettings(projectRoot, {
526
+ runtimeURL: valueFor(argv, "--runtime-url"),
527
+ projectID: valueFor(argv, "--project-id"),
528
+ key: valueFor(argv, "--key"),
529
+ });
530
+ if (!settings.key)
531
+ throw new Error("GONVEX_PROJECT_KEY is required for project auth commands");
532
+ const endpoint = `${settings.runtimeURL}/dev/projects/${encodeURIComponent(settings.projectID)}/auth/google`;
533
+ if (action === "add" || action === "enable") {
534
+ const provider = positional[1];
535
+ if (provider !== "google")
536
+ throw new Error("usage: gonvex auth add google [--origin URL]");
537
+ const callbackPath = normalizeAuthCallbackPath(valueFor(argv, "--callback-path") ?? "/");
538
+ const signupMode = normalizeAuthSignupMode(valueFor(argv, "--signup-mode") ?? "personal");
539
+ const suppliedOrigins = valuesFor(argv, "--origin");
540
+ const rawOrigins = suppliedOrigins.length > 0 ? suppliedOrigins : [
541
+ process.env.GONVEX_APP_ORIGIN ?? process.env.VITE_APP_URL ?? "http://localhost:5173",
542
+ ];
543
+ const redirectUris = [...new Set(rawOrigins.map((raw) => new URL(callbackPath, `${normalizeAppOrigin(raw)}/`).toString()))];
544
+ let configured = null;
545
+ for (const redirectUri of redirectUris) {
546
+ configured = await runtimeJSON(await fetch(endpoint, {
547
+ method: "PUT",
548
+ headers: { ...projectAuthHeaders(settings), "content-type": "application/json" },
549
+ body: JSON.stringify({ redirectUri, signupMode }),
550
+ }));
551
+ }
552
+ await saveGoogleAuthProjectConfig(projectRoot, callbackPath, redirectUris, signupMode);
553
+ await writeGonvexAuthModule(projectRoot, settings, callbackPath);
554
+ const wired = await wireViteReactGoogleAuth(projectRoot);
555
+ console.log(`[gonvex] enabled Google auth for ${settings.projectID}`);
556
+ for (const redirectUri of redirectUris)
557
+ console.log(`[gonvex] registered callback ${redirectUri}`);
558
+ console.log(`[gonvex] wrote gonvex/auth.tsx; wrap your app with GonvexAuthProvider and render GoogleSignInButton`);
559
+ if (wired)
560
+ console.log("[gonvex] wired the Vite React starter with a production-ready sign-in screen");
561
+ if (!configured?.ready) {
562
+ console.warn(`[gonvex] Google auth is registered but the runtime is not production-ready: ${(configured?.issues ?? ["runtime broker configuration is incomplete"]).join("; ")}`);
563
+ }
564
+ return;
565
+ }
566
+ if (action === "remove" || action === "disable") {
567
+ const provider = positional[1];
568
+ if (provider !== "google")
569
+ throw new Error("usage: gonvex auth remove google");
570
+ const suppliedOrigins = valuesFor(argv, "--origin");
571
+ if (suppliedOrigins.length > 0) {
572
+ const localConfig = await loadConfig(projectRoot);
573
+ const callbackPath = normalizeAuthCallbackPath(valueFor(argv, "--callback-path") ?? localConfig.auth?.providers?.google?.callbackPath ?? "/");
574
+ const redirectUris = [...new Set(suppliedOrigins.map((raw) => new URL(callbackPath, `${normalizeAppOrigin(raw)}/`).toString()))];
575
+ for (const redirectUri of redirectUris) {
576
+ const url = new URL(endpoint);
577
+ url.searchParams.set("redirect_uri", redirectUri);
578
+ await runtimeJSON(await fetch(url, { method: "DELETE", headers: projectAuthHeaders(settings) }));
579
+ }
580
+ await removeGoogleAuthProjectRedirects(projectRoot, redirectUris);
581
+ for (const redirectUri of redirectUris)
582
+ console.log(`[gonvex] removed Google callback ${redirectUri}`);
583
+ return;
584
+ }
585
+ await runtimeJSON(await fetch(endpoint, { method: "DELETE", headers: projectAuthHeaders(settings) }));
586
+ await setGoogleAuthProjectEnabled(projectRoot, false);
587
+ console.log(`[gonvex] disabled Google auth for ${settings.projectID}`);
588
+ return;
589
+ }
590
+ if (action === "status") {
591
+ const configured = await runtimeJSON(await fetch(endpoint, { headers: projectAuthHeaders(settings) }));
592
+ if (argv.includes("--json")) {
593
+ console.log(JSON.stringify(configured, null, 2));
594
+ return;
595
+ }
596
+ console.log(`Google: ${configured.enabled ? "enabled" : "disabled"}`);
597
+ console.log(`Broker: ${configured.ready ? "ready" : "not ready"}`);
598
+ console.log(`Signup: ${configured.signupMode ?? "personal"}`);
599
+ for (const redirectUri of configured.redirectUris ?? [])
600
+ console.log(`Callback: ${redirectUri}`);
601
+ if (configured.brokerCallbackUrl)
602
+ console.log(`Google Cloud redirect URI: ${configured.brokerCallbackUrl}`);
603
+ for (const issue of configured.issues ?? [])
604
+ console.log(`Issue: ${issue}`);
605
+ return;
606
+ }
607
+ if (action === "doctor") {
608
+ const configured = await runtimeJSON(await fetch(endpoint, { headers: projectAuthHeaders(settings) }));
609
+ const localConfig = await loadConfig(projectRoot);
610
+ const localRedirects = localConfig.auth?.providers?.google?.redirectUris ?? [];
611
+ const missing = localRedirects.filter((redirectUri) => !(configured.redirectUris ?? []).includes(redirectUri));
612
+ const untracked = (configured.redirectUris ?? []).filter((redirectUri) => !localRedirects.includes(redirectUri));
613
+ const issues = [...(configured.issues ?? [])];
614
+ if (!configured.enabled)
615
+ issues.push("Google provider is disabled for this project");
616
+ for (const redirectUri of missing)
617
+ issues.push(`local callback is not registered: ${redirectUri}`);
618
+ for (const redirectUri of untracked)
619
+ issues.push(`runtime callback is not tracked in gonvex.json: ${redirectUri}`);
620
+ if ((configured.redirectUris ?? []).length === 0)
621
+ issues.push("no app callback is registered");
622
+ if (configured.signupMode === "inviteOnly" && (configured.tenantCount ?? 0) === 0)
623
+ issues.push("invite-only signup needs at least one tenant scope");
624
+ if (configured.signupMode === "inviteOnly" && (configured.membershipCount ?? 0) + (configured.invitationCount ?? 0) === 0)
625
+ issues.push("invite-only signup needs at least one member or invitation");
626
+ if (argv.includes("--json")) {
627
+ console.log(JSON.stringify({ ...configured, issues, ready: configured.ready && issues.length === 0 }, null, 2));
628
+ }
629
+ else if (configured.ready && issues.length === 0) {
630
+ console.log(`[gonvex] Google auth is ready for production (${configured.brokerCallbackUrl})`);
631
+ for (const redirectUri of configured.redirectUris ?? [])
632
+ console.log(`Callback: ${redirectUri}`);
633
+ }
634
+ else {
635
+ for (const issue of issues)
636
+ console.error(`[gonvex] ${issue}`);
637
+ }
638
+ if (!configured.ready || issues.length > 0)
639
+ throw new Error("Google auth production readiness check failed");
640
+ return;
641
+ }
642
+ if (action === "users" || action === "accounts") {
643
+ const usersEndpoint = `${settings.runtimeURL}/dev/projects/${encodeURIComponent(settings.projectID)}/auth/users`;
644
+ const payload = await runtimeJSON(await fetch(usersEndpoint, { headers: projectAuthHeaders(settings) }));
645
+ const users = payload.users ?? [];
646
+ if (argv.includes("--json")) {
647
+ console.log(JSON.stringify(users, null, 2));
648
+ return;
649
+ }
650
+ if (users.length === 0) {
651
+ console.log(`[gonvex] no app accounts for ${settings.projectID}`);
652
+ return;
653
+ }
654
+ for (const user of users) {
655
+ console.log(`${user.id}\t${user.email ?? ""}\t${user.name ?? ""}\t${user.provider}`);
656
+ }
657
+ return;
658
+ }
659
+ if (action === "tenants" || action === "tenant") {
660
+ const operation = positional[1] ?? "list";
661
+ const tenantsEndpoint = `${settings.runtimeURL}/dev/projects/${encodeURIComponent(settings.projectID)}/auth/tenants`;
662
+ if (operation === "list" || operation === "ls") {
663
+ const payload = await runtimeJSON(await fetch(tenantsEndpoint, { headers: projectAuthHeaders(settings) }));
664
+ if (argv.includes("--json")) {
665
+ console.log(JSON.stringify(payload.tenants ?? [], null, 2));
666
+ return;
667
+ }
668
+ for (const tenant of payload.tenants ?? [])
669
+ console.log(`${tenant.id}\t${tenant.name}\t${tenant.memberCount} member(s)`);
670
+ return;
671
+ }
672
+ if (operation === "create") {
673
+ const name = positional[2];
674
+ if (!name)
675
+ throw new Error("usage: gonvex auth tenants create <name> [--owner email]");
676
+ const payload = await runtimeJSON(await fetch(tenantsEndpoint, {
677
+ method: "POST", headers: { ...projectAuthHeaders(settings), "content-type": "application/json" },
678
+ body: JSON.stringify({ name, ownerEmail: valueFor(argv, "--owner") ?? "" }),
679
+ }));
680
+ console.log(`[gonvex] created tenant ${payload.tenant.name} (${payload.tenant.id})`);
681
+ return;
682
+ }
683
+ throw new Error(`unknown auth tenants command ${operation}`);
684
+ }
685
+ if (action === "members" || action === "memberships") {
686
+ const operation = positional[1] ?? "list";
687
+ const tenant = valueFor(argv, "--tenant") ?? positional[2];
688
+ if (!tenant)
689
+ throw new Error("--tenant is required for membership commands");
690
+ const membershipEndpoint = `${settings.runtimeURL}/dev/projects/${encodeURIComponent(settings.projectID)}/auth/memberships?tenant=${encodeURIComponent(tenant)}`;
691
+ if (operation === "list" || operation === "ls") {
692
+ const payload = await runtimeJSON(await fetch(membershipEndpoint, { headers: projectAuthHeaders(settings) }));
693
+ if (argv.includes("--json")) {
694
+ console.log(JSON.stringify(payload, null, 2));
695
+ return;
696
+ }
697
+ for (const member of payload.members ?? [])
698
+ console.log(`${member.userId}\t${member.email}\t${member.role}\t${member.name}`);
699
+ for (const invitation of payload.invitations ?? [])
700
+ console.log(`invited\t${invitation.email}\t${invitation.role}\t${invitation.expiresAt ?? ""}`);
701
+ return;
702
+ }
703
+ if (operation === "add" || operation === "invite") {
704
+ const email = valueFor(argv, "--email") ?? positional[2];
705
+ if (!email)
706
+ throw new Error("--email is required when adding a membership");
707
+ const role = valueFor(argv, "--role") ?? "member";
708
+ await runtimeJSON(await fetch(membershipEndpoint, {
709
+ method: "PUT", headers: { ...projectAuthHeaders(settings), "content-type": "application/json" },
710
+ body: JSON.stringify({ email, role }),
711
+ }));
712
+ console.log(`[gonvex] granted ${role} access to ${email} for tenant ${tenant}`);
713
+ return;
714
+ }
715
+ if (operation === "remove" || operation === "rm") {
716
+ const user = valueFor(argv, "--user");
717
+ const email = valueFor(argv, "--email");
718
+ if (!user && !email)
719
+ throw new Error("--user is required to remove a member, or --email to revoke an invitation");
720
+ const target = user ? `user=${encodeURIComponent(user)}` : `email=${encodeURIComponent(email)}`;
721
+ await runtimeJSON(await fetch(`${membershipEndpoint}&${target}`, {
722
+ method: "DELETE", headers: projectAuthHeaders(settings),
723
+ }));
724
+ console.log(user
725
+ ? `[gonvex] removed ${user} from tenant ${tenant}`
726
+ : `[gonvex] revoked the invitation for ${email} from tenant ${tenant}`);
727
+ return;
728
+ }
729
+ throw new Error(`unknown auth memberships command ${operation}`);
730
+ }
731
+ if (action === "user") {
732
+ const operation = positional[1];
733
+ const user = valueFor(argv, "--user") ?? positional[2];
734
+ if (!operation || !user)
735
+ throw new Error("usage: gonvex auth user <disable|enable|delete> <user-id>");
736
+ const userEndpoint = `${settings.runtimeURL}/dev/projects/${encodeURIComponent(settings.projectID)}/auth/users/${encodeURIComponent(user)}`;
737
+ if (operation === "disable" || operation === "enable") {
738
+ await runtimeJSON(await fetch(userEndpoint, {
739
+ method: "PATCH", headers: { ...projectAuthHeaders(settings), "content-type": "application/json" },
740
+ body: JSON.stringify({ disabled: operation === "disable" }),
741
+ }));
742
+ console.log(`[gonvex] ${operation}d app account ${user}`);
743
+ return;
744
+ }
745
+ if (operation === "delete" || operation === "remove") {
746
+ await runtimeJSON(await fetch(userEndpoint, { method: "DELETE", headers: projectAuthHeaders(settings) }));
747
+ console.log(`[gonvex] deleted app account ${user}`);
748
+ return;
749
+ }
750
+ throw new Error(`unknown auth user command ${operation}`);
751
+ }
752
+ printAuthHelp();
753
+ throw new Error(`unknown auth command ${action}`);
754
+ }
755
+ function normalizeAppOrigin(raw) {
756
+ let parsed;
757
+ try {
758
+ parsed = new URL(raw);
759
+ }
760
+ catch {
761
+ throw new Error(`invalid app origin ${raw}`);
762
+ }
763
+ if (parsed.username || parsed.password || parsed.pathname !== "/" || parsed.search || parsed.hash) {
764
+ throw new Error("--origin must contain only scheme, host, and optional port");
765
+ }
766
+ const local = ["localhost", "127.0.0.1", "[::1]", "::1"].includes(parsed.hostname);
767
+ if (parsed.protocol !== "https:" && !(parsed.protocol === "http:" && local)) {
768
+ throw new Error("--origin must use https (http is allowed only for localhost)");
769
+ }
770
+ return parsed.origin;
771
+ }
772
+ function normalizeAuthCallbackPath(raw) {
773
+ const value = raw.trim();
774
+ if (!value.startsWith("/") || value.startsWith("//") || value.includes("?") || value.includes("#")) {
775
+ throw new Error("--callback-path must be an absolute pathname without a query or fragment");
776
+ }
777
+ return value;
778
+ }
779
+ function normalizeAuthSignupMode(raw) {
780
+ if (raw === "personal")
781
+ return raw;
782
+ if (raw === "inviteOnly" || raw === "invite-only" || raw === "invited")
783
+ return "inviteOnly";
784
+ throw new Error("--signup-mode must be personal or inviteOnly");
785
+ }
786
+ async function saveGoogleAuthProjectConfig(root, callbackPath, addedRedirectUris, signupMode) {
787
+ const configPath = join(root, "gonvex.json");
788
+ const config = await loadConfig(root);
789
+ const existing = config.auth?.providers?.google;
790
+ const redirectUris = [...new Set([...(existing?.redirectUris ?? []), ...addedRedirectUris])].sort();
791
+ config.auth = {
792
+ ...(config.auth ?? {}),
793
+ providers: {
794
+ ...(config.auth?.providers ?? {}),
795
+ google: { ...(existing ?? {}), enabled: true, callbackPath, redirectUris, signupMode },
796
+ },
797
+ };
798
+ await writeFile(configPath, `${JSON.stringify(config, null, 2)}\n`);
799
+ }
800
+ async function setGoogleAuthProjectEnabled(root, enabled) {
801
+ const configPath = join(root, "gonvex.json");
802
+ const config = await loadConfig(root);
803
+ const existing = config.auth?.providers?.google ?? {};
804
+ config.auth = {
805
+ ...(config.auth ?? {}),
806
+ providers: { ...(config.auth?.providers ?? {}), google: { ...existing, enabled } },
807
+ };
808
+ await writeFile(configPath, `${JSON.stringify(config, null, 2)}\n`);
809
+ }
810
+ async function removeGoogleAuthProjectRedirects(root, removedRedirectUris) {
811
+ const configPath = join(root, "gonvex.json");
812
+ const config = await loadConfig(root);
813
+ const existing = config.auth?.providers?.google ?? {};
814
+ const removed = new Set(removedRedirectUris);
815
+ config.auth = {
816
+ ...(config.auth ?? {}),
817
+ providers: {
818
+ ...(config.auth?.providers ?? {}),
819
+ google: { ...existing, redirectUris: (existing.redirectUris ?? []).filter((redirectUri) => !removed.has(redirectUri)) },
820
+ },
821
+ };
822
+ await writeFile(configPath, `${JSON.stringify(config, null, 2)}\n`);
823
+ }
824
+ async function writeGonvexAuthModule(root, settings, callbackPath) {
825
+ const source = [
826
+ "// Generated by `gonvex auth add google`. Re-running the command replaces this file.",
827
+ 'import { createGonvexAuth } from "./_generated/react";',
828
+ "",
829
+ "const env = (import.meta as ImportMeta & { env?: Record<string, string | undefined> }).env ?? {};",
830
+ "",
831
+ "export const gonvexAuth = createGonvexAuth({",
832
+ ` runtimeUrl: env.VITE_GONVEX_URL ?? env.VITE_GONVEX_RUNTIME_URL ?? ${JSON.stringify(settings.runtimeURL)},`,
833
+ ` projectId: env.VITE_GONVEX_PROJECT_ID ?? ${JSON.stringify(settings.projectID)},`,
834
+ ` callbackPath: ${JSON.stringify(callbackPath)},`,
835
+ "});",
836
+ "",
837
+ "export const GonvexAuthProvider = gonvexAuth.GonvexAuthProvider;",
838
+ "export const GoogleSignInButton = gonvexAuth.GoogleSignInButton;",
839
+ "export const useGonvexAuth = gonvexAuth.useGonvexAuth;",
840
+ "",
841
+ ].join("\n");
842
+ await writeFileIfChanged(join(root, "gonvex", "auth.tsx"), source);
843
+ }
844
+ async function wireViteReactGoogleAuth(root) {
845
+ const mainPath = join(root, "src", "main.tsx");
846
+ const appPath = join(root, "src", "App.tsx");
847
+ if (!existsSync(mainPath) || !existsSync(appPath))
848
+ return false;
849
+ let main = await readFile(mainPath, "utf8");
850
+ let app = await readFile(appPath, "utf8");
851
+ if (main.includes('from "../gonvex/auth"') && app.includes("GoogleSignInButton"))
852
+ return true;
853
+ const providerImport = 'import { GonvexProvider } from "../gonvex/_generated/react";';
854
+ const reactImport = 'import { useMutation, useQuery } from "../gonvex/_generated/react";';
855
+ const appStart = 'export default function App(props: { runtimeURL: string }) {\n const messages = useQuery<Message[]>(api["messages.list"], {}) ?? [];';
856
+ if (!main.includes(providerImport) || !main.includes("<GonvexProvider client={gonvex}>") || !app.includes(reactImport) || !app.includes(appStart)) {
857
+ return false;
858
+ }
859
+ main = main
860
+ .replace(providerImport, 'import { GonvexAuthProvider } from "../gonvex/auth";')
861
+ .replace("<GonvexProvider client={gonvex}>", "<GonvexAuthProvider client={gonvex}>")
862
+ .replace("</GonvexProvider>", "</GonvexAuthProvider>");
863
+ app = app
864
+ .replace(reactImport, `${reactImport}\nimport { GoogleSignInButton, useGonvexAuth } from "../gonvex/auth";`)
865
+ .replace(appStart, [
866
+ "export default function App(props: { runtimeURL: string }) {",
867
+ " const auth = useGonvexAuth();",
868
+ " if (!auth.isAuthenticated) {",
869
+ ' return <main className="shell"><section className="hero"><div className="status">Secure Gonvex account</div><h1>Sign in to continue.</h1><p>Your app uses Gonvex-native Google authentication—no Firebase SDK or per-app Google Cloud project.</p><GoogleSignInButton />{auth.error ? <p role="alert">{auth.error}</p> : null}</section></main>;',
870
+ " }",
871
+ " return <AuthenticatedApp runtimeURL={props.runtimeURL} />;",
872
+ "}",
873
+ "",
874
+ "function AuthenticatedApp(props: { runtimeURL: string }) {",
875
+ " const auth = useGonvexAuth();",
876
+ ' const messages = useQuery<Message[]>(api["messages.list"], {}) ?? [];',
877
+ ].join("\n"))
878
+ .replace('<div className="status">Connected to {props.runtimeURL}</div>', '<div className="status"><span>Connected to {props.runtimeURL} as {auth.user?.email}</span><GoogleSignInButton /></div>');
879
+ await writeFile(mainPath, main);
880
+ await writeFile(appPath, app);
881
+ return true;
882
+ }
205
883
  async function watchProject(root, settings, once, signal, initialState) {
206
884
  const backendDir = join(root, "gonvex");
207
885
  await mkdir(backendDir, { recursive: true });
@@ -322,7 +1000,7 @@ function sanitizeProjectID(projectID) {
322
1000
  }
323
1001
  async function parseRegistrations(root, file) {
324
1002
  const source = await readFile(file, "utf8");
325
- const pattern = /app\.(Query|Mutation|Action|HTTP|InternalMutation|LiveGrid)\(\s*"([^"]+)"\s*,\s*([A-Za-z_][A-Za-z0-9_]*)/g;
1003
+ const pattern = /app\.(Query|Mutation|Action|HTTP|PublicHTTP|InternalMutation|LiveGrid)\(\s*"([^"]+)"\s*,\s*([A-Za-z_][A-Za-z0-9_]*)/g;
326
1004
  const entries = {};
327
1005
  for (const match of source.matchAll(pattern)) {
328
1006
  entries[match[2]] = {
@@ -378,7 +1056,7 @@ async function writeBindings(root, manifest) {
378
1056
  const outputs = {
379
1057
  "api.ts": renderAPI(manifest),
380
1058
  "client.ts": '// Generated by gonvex dev. Do not edit.\nexport { GonvexClient, ConvexReactClient } from "@gonvex/client";\n',
381
- "react.ts": '// Generated by gonvex dev. Do not edit.\nexport { ConvexProvider, ConvexProviderWithAuth, ConvexReactClient, GonvexProvider, useAction, useConvex, useConvexAuth, useConvexConnectionState, useMutation, usePaginatedQuery, useQuery } from "@gonvex/react";\n',
1059
+ "react.ts": '// Generated by gonvex dev. Do not edit.\nexport { ConvexProvider, ConvexProviderWithAuth, ConvexReactClient, createGonvexAuth, GonvexAuthProvider, GonvexGoogleAuthButton, GonvexProvider, useAction, useConvex, useConvexAuth, useConvexConnectionState, useGonvexAuth, useMutation, usePaginatedQuery, useQuery } from "@gonvex/react";\nexport type { GonvexAuthConfig, GonvexAuthTenant, GonvexAuthUser, GonvexAuthValue } from "@gonvex/react";\n',
382
1060
  "types.ts": "// Generated by gonvex dev. Do not edit.\nexport type JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue };\n",
383
1061
  "schema.ts": renderSchemaIndex(manifest),
384
1062
  "landlord/schema.ts": renderScopedSchemaModule("landlord", manifest.schema.landlordTables),
@@ -727,11 +1405,18 @@ async function ensureProjectSettings(root, settings, options) {
727
1405
  return settings;
728
1406
  if (options.keyWasExplicit)
729
1407
  return settings;
730
- const configuredProject = await findRuntimeProject(settings.runtimeURL, settings.projectID).catch(() => null);
1408
+ let accountToken = await accountAccessTokenForRuntime(settings.runtimeURL);
1409
+ const configuredProject = await findRuntimeProject(settings.runtimeURL, settings.projectID, accountToken).catch(() => null);
731
1410
  if (configuredProject) {
1411
+ if (accountToken) {
1412
+ const projectKey = await fetchRuntimeProjectKey(settings.runtimeURL, configuredProject.id, accountToken);
1413
+ await writeProjectEnv(root, settings.runtimeURL, configuredProject.id, projectKey);
1414
+ console.log(`[gonvex] configured ${configuredProject.id} in .env.local`);
1415
+ return { ...settings, projectID: configuredProject.id, key: projectKey };
1416
+ }
732
1417
  await writeProjectEnv(root, settings.runtimeURL, configuredProject.id, settings.key);
733
1418
  console.log(`[gonvex] configured ${configuredProject.id} in .env.local`);
734
- console.warn("[gonvex] GONVEX_PROJECT_KEY is not configured; runtime sync will fail if the runtime requires project keys.");
1419
+ console.warn("[gonvex] GONVEX_PROJECT_KEY is not configured; run 'gonvex login' or provide the project key.");
735
1420
  return { ...settings, projectID: configuredProject.id };
736
1421
  }
737
1422
  if (!process.stdin.isTTY || !process.stdout.isTTY) {
@@ -743,7 +1428,8 @@ async function ensureProjectSettings(root, settings, options) {
743
1428
  console.log("[gonvex] No Gonvex project is configured for this app.");
744
1429
  const runtimeURL = await promptDefault(rl, "Runtime URL", settings.runtimeURL || defaultRuntimeURL);
745
1430
  settings = { ...settings, runtimeURL };
746
- const projects = await fetchRuntimeProjects(runtimeURL);
1431
+ accountToken = await accountAccessTokenForRuntime(runtimeURL);
1432
+ const projects = await fetchRuntimeProjects(runtimeURL, accountToken);
747
1433
  let project;
748
1434
  if (projects.length > 0) {
749
1435
  console.log("[gonvex] Choose a project:");
@@ -755,7 +1441,9 @@ async function ensureProjectSettings(root, settings, options) {
755
1441
  const index = Number.parseInt(choice, 10);
756
1442
  if (Number.isFinite(index) && index >= 1 && index <= projects.length) {
757
1443
  project = projects[index - 1];
758
- const projectKey = await promptDefault(rl, "Project key from dashboard", "");
1444
+ const projectKey = accountToken
1445
+ ? await fetchRuntimeProjectKey(runtimeURL, project.id, accountToken)
1446
+ : await promptDefault(rl, "Project key", "");
759
1447
  if (!projectKey)
760
1448
  throw new Error("Gonvex project key is required for existing projects");
761
1449
  const next = { ...settings, projectID: project.id, key: projectKey };
@@ -764,7 +1452,7 @@ async function ensureProjectSettings(root, settings, options) {
764
1452
  return next;
765
1453
  }
766
1454
  else {
767
- const created = await createRuntimeProject(runtimeURL, await promptDefault(rl, "New project name", basename(root)));
1455
+ const created = await createRuntimeProject(runtimeURL, await promptDefault(rl, "New project name", basename(root)), accountToken);
768
1456
  project = created.project;
769
1457
  const next = { ...settings, projectID: project.id, key: created.projectKey };
770
1458
  await writeProjectEnv(root, runtimeURL, project.id, created.projectKey);
@@ -773,7 +1461,7 @@ async function ensureProjectSettings(root, settings, options) {
773
1461
  }
774
1462
  }
775
1463
  else {
776
- const created = await createRuntimeProject(runtimeURL, await promptDefault(rl, "New project name", basename(root)));
1464
+ const created = await createRuntimeProject(runtimeURL, await promptDefault(rl, "New project name", basename(root)), accountToken);
777
1465
  project = created.project;
778
1466
  const next = { ...settings, projectID: project.id, key: created.projectKey };
779
1467
  await writeProjectEnv(root, runtimeURL, project.id, created.projectKey);
@@ -785,38 +1473,48 @@ async function ensureProjectSettings(root, settings, options) {
785
1473
  rl.close();
786
1474
  }
787
1475
  }
788
- async function findRuntimeProject(runtimeURL, projectID) {
1476
+ async function findRuntimeProject(runtimeURL, projectID, accountToken) {
789
1477
  const wanted = projectID.trim();
790
1478
  if (!wanted)
791
1479
  return null;
792
- const projects = await fetchRuntimeProjects(runtimeURL);
1480
+ const projects = await fetchRuntimeProjects(runtimeURL, accountToken);
793
1481
  return projects.find((project) => project.id === wanted) ?? null;
794
1482
  }
795
- async function fetchRuntimeProjects(runtimeURL) {
796
- const response = await fetch(`${runtimeURL.replace(/\/$/, "")}/dev/projects`);
797
- if (!response.ok)
798
- throw new Error(`runtime returned ${response.status} ${response.statusText}: ${await response.text()}`);
799
- const payload = await response.json();
1483
+ async function fetchRuntimeProjects(runtimeURL, accountToken) {
1484
+ const response = await fetch(`${runtimeURL.replace(/\/$/, "")}/dev/projects`, {
1485
+ headers: accountToken ? accountHeaders(accountToken) : undefined,
1486
+ });
1487
+ const payload = await runtimeJSON(response);
800
1488
  return payload.projects ?? [];
801
1489
  }
802
- async function createRuntimeProject(runtimeURL, name) {
1490
+ async function createRuntimeProject(runtimeURL, name, accountToken, databaseMode = "single") {
803
1491
  const response = await fetch(`${runtimeURL.replace(/\/$/, "")}/dev/projects`, {
804
1492
  method: "POST",
805
- headers: { "content-type": "application/json" },
806
- body: JSON.stringify({ name }),
1493
+ headers: { ...(accountToken ? accountHeaders(accountToken) : {}), "content-type": "application/json" },
1494
+ body: JSON.stringify({ name, databaseMode }),
807
1495
  });
808
- if (!response.ok)
809
- throw new Error(`runtime returned ${response.status} ${response.statusText}: ${await response.text()}`);
810
- const payload = await response.json();
1496
+ const payload = await runtimeJSON(response);
1497
+ if (!payload.project?.id)
1498
+ throw new Error("runtime did not return project details");
811
1499
  if (!payload.projectKey)
812
1500
  throw new Error("runtime did not return a project key");
813
1501
  return { project: payload.project, projectKey: payload.projectKey };
814
1502
  }
1503
+ async function fetchRuntimeProjectKey(runtimeURL, projectID, accountToken) {
1504
+ const response = await fetch(`${runtimeURL.replace(/\/$/, "")}/dev/projects/${encodeURIComponent(projectID)}/key`, {
1505
+ method: "POST",
1506
+ headers: accountToken ? accountHeaders(accountToken) : undefined,
1507
+ });
1508
+ const payload = await runtimeJSON(response);
1509
+ if (!payload.projectKey)
1510
+ throw new Error("runtime did not return a project key");
1511
+ return payload.projectKey;
1512
+ }
815
1513
  async function promptDefault(rl, label, fallback) {
816
1514
  const answer = (await rl.question(`${label} (${fallback}): `)).trim();
817
1515
  return answer || fallback;
818
1516
  }
819
- async function writeProjectEnv(root, runtimeURL, projectID, projectKey) {
1517
+ async function writeProjectEnv(root, runtimeURL, projectID, projectKey, overwrite = false) {
820
1518
  await upsertEnvLocal(root, {
821
1519
  GONVEX_PROJECT_ID: projectID,
822
1520
  GONVEX_RUNTIME_URL: runtimeURL,
@@ -824,9 +1522,9 @@ async function writeProjectEnv(root, runtimeURL, projectID, projectKey) {
824
1522
  VITE_GONVEX_PROJECT_ID: projectID,
825
1523
  VITE_GONVEX_URL: runtimeURL,
826
1524
  VITE_GONVEX_WS_URL: webSocketURL(runtimeURL),
827
- });
1525
+ }, overwrite);
828
1526
  }
829
- async function upsertEnvLocal(root, values) {
1527
+ async function upsertEnvLocal(root, values, overwrite = false) {
830
1528
  const envPath = join(root, ".env.local");
831
1529
  const existing = existsSync(envPath) ? await readFile(envPath, "utf8") : "";
832
1530
  const seen = new Set();
@@ -841,7 +1539,7 @@ async function upsertEnvLocal(root, values) {
841
1539
  if (seen.has(key))
842
1540
  return [];
843
1541
  seen.add(key);
844
- if (envLineValue(line) !== "")
1542
+ if (!overwrite && envLineValue(line) !== "")
845
1543
  return [line];
846
1544
  return [`${key}=${values[key]}`];
847
1545
  });
@@ -849,7 +1547,8 @@ async function upsertEnvLocal(root, values) {
849
1547
  if (!seen.has(key))
850
1548
  next.push(`${key}=${value}`);
851
1549
  }
852
- await writeFile(envPath, `${next.join("\n")}\n`);
1550
+ await writeFile(envPath, `${next.join("\n")}\n`, { mode: 0o600 });
1551
+ await chmod(envPath, 0o600).catch(() => { });
853
1552
  }
854
1553
  function envLineValue(line) {
855
1554
  const index = line.indexOf("=");
@@ -947,21 +1646,172 @@ function shouldPrintRuntimeLogEntry(entry, options) {
947
1646
  const outcome = String(entry.outcome ?? "").toLowerCase();
948
1647
  return Boolean(entry.error) || outcome === "error" || outcome === "warn" || outcome === "warning";
949
1648
  }
1649
+ function normalizeRuntimeURL(value) {
1650
+ const trimmed = value.trim().replace(/\/+$/, "");
1651
+ if (!trimmed)
1652
+ return defaultRuntimeURL;
1653
+ let parsed;
1654
+ try {
1655
+ parsed = new URL(trimmed);
1656
+ }
1657
+ catch {
1658
+ throw new Error(`invalid runtime URL ${value}`);
1659
+ }
1660
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:")
1661
+ throw new Error("runtime URL must use http or https");
1662
+ return parsed.toString().replace(/\/+$/, "");
1663
+ }
1664
+ function cliConfigPath() {
1665
+ if (process.env.GONVEX_CONFIG_PATH)
1666
+ return resolve(process.env.GONVEX_CONFIG_PATH);
1667
+ const configRoot = process.env.XDG_CONFIG_HOME ? resolve(process.env.XDG_CONFIG_HOME) : join(homedir(), ".config");
1668
+ return join(configRoot, "gonvex", "config.json");
1669
+ }
1670
+ function emptyCLIConfig() {
1671
+ return { version: 1, runtimes: {} };
1672
+ }
1673
+ async function readCLIConfig() {
1674
+ const path = cliConfigPath();
1675
+ if (!existsSync(path))
1676
+ return emptyCLIConfig();
1677
+ let parsed;
1678
+ try {
1679
+ parsed = JSON.parse(await readFile(path, "utf8"));
1680
+ }
1681
+ catch (error) {
1682
+ throw new Error(`could not read Gonvex CLI config ${path}: ${error instanceof Error ? error.message : String(error)}`);
1683
+ }
1684
+ return {
1685
+ version: 1,
1686
+ currentRuntime: parsed.currentRuntime,
1687
+ runtimes: parsed.runtimes && typeof parsed.runtimes === "object" ? parsed.runtimes : {},
1688
+ };
1689
+ }
1690
+ async function writeCLIConfig(config) {
1691
+ const path = cliConfigPath();
1692
+ await mkdir(dirname(path), { recursive: true, mode: 0o700 });
1693
+ await writeFile(path, `${JSON.stringify(config, null, 2)}\n`, { mode: 0o600 });
1694
+ await chmod(path, 0o600).catch(() => { });
1695
+ }
1696
+ async function saveAccountProfile(runtimeURL, accessToken, identity, expiresAt) {
1697
+ runtimeURL = normalizeRuntimeURL(runtimeURL);
1698
+ const config = await readCLIConfig();
1699
+ config.currentRuntime = runtimeURL;
1700
+ config.runtimes[runtimeURL] = {
1701
+ runtimeURL,
1702
+ accessToken,
1703
+ email: identity.account.email,
1704
+ name: identity.account.name,
1705
+ authentication: identity.authentication,
1706
+ permissions: identity.permissions,
1707
+ ...(expiresAt ? { expiresAt } : {}),
1708
+ };
1709
+ await writeCLIConfig(config);
1710
+ }
1711
+ async function removeAccountProfile(runtimeURL) {
1712
+ runtimeURL = normalizeRuntimeURL(runtimeURL);
1713
+ const config = await readCLIConfig();
1714
+ if (!config.runtimes[runtimeURL])
1715
+ return false;
1716
+ delete config.runtimes[runtimeURL];
1717
+ if (config.currentRuntime === runtimeURL)
1718
+ config.currentRuntime = Object.keys(config.runtimes)[0];
1719
+ await writeCLIConfig(config);
1720
+ return true;
1721
+ }
1722
+ async function accountRuntimeForArgs(argv) {
1723
+ const explicit = valueFor(argv, "--runtime-url") ?? valueFor(argv, "--runtime") ?? process.env.GONVEX_RUNTIME_URL;
1724
+ if (explicit)
1725
+ return normalizeRuntimeURL(explicit);
1726
+ const config = await readCLIConfig();
1727
+ return normalizeRuntimeURL(config.currentRuntime ?? defaultRuntimeURL);
1728
+ }
1729
+ async function accountAccessTokenForRuntime(runtimeURL) {
1730
+ if (process.env.GONVEX_ACCOUNT_TOKEN?.trim())
1731
+ return process.env.GONVEX_ACCOUNT_TOKEN.trim();
1732
+ const config = await readCLIConfig();
1733
+ const profile = config.runtimes[normalizeRuntimeURL(runtimeURL)];
1734
+ if (profile?.expiresAt && profile.expiresAt <= Date.now()) {
1735
+ throw new Error(`saved login for ${normalizeRuntimeURL(runtimeURL)} has expired; run 'gonvex login' again`);
1736
+ }
1737
+ return profile?.accessToken;
1738
+ }
1739
+ async function requireAccountAccessToken(runtimeURL) {
1740
+ const token = await accountAccessTokenForRuntime(runtimeURL);
1741
+ if (!token)
1742
+ throw new Error(`not logged in to ${normalizeRuntimeURL(runtimeURL)}; run 'gonvex login --runtime-url ${normalizeRuntimeURL(runtimeURL)}'`);
1743
+ return token;
1744
+ }
1745
+ function accountHeaders(accessToken) {
1746
+ return { authorization: `Bearer ${accessToken}` };
1747
+ }
1748
+ async function runtimeJSON(response) {
1749
+ const text = await response.text();
1750
+ let payload = {};
1751
+ if (text) {
1752
+ try {
1753
+ payload = JSON.parse(text);
1754
+ }
1755
+ catch {
1756
+ payload = { error: text };
1757
+ }
1758
+ }
1759
+ if (!response.ok) {
1760
+ const error = payload && typeof payload === "object" && "error" in payload ? String(payload.error) : response.statusText;
1761
+ const permission = payload && typeof payload === "object" && "permission" in payload ? ` (${String(payload.permission)})` : "";
1762
+ throw new Error(`runtime returned ${response.status}: ${error}${permission}`);
1763
+ }
1764
+ return payload;
1765
+ }
1766
+ async function fetchAccountIdentity(runtimeURL, accessToken) {
1767
+ const response = await fetch(`${normalizeRuntimeURL(runtimeURL)}/dev/auth/me`, { headers: accountHeaders(accessToken) });
1768
+ return runtimeJSON(response);
1769
+ }
1770
+ async function promptHidden(label) {
1771
+ if (!process.stdin.isTTY || !process.stdout.isTTY)
1772
+ throw new Error("password must be provided non-interactively with --password or GONVEX_PASSWORD");
1773
+ let hidden = false;
1774
+ const output = new Writable({
1775
+ write(chunk, encoding, callback) {
1776
+ if (!hidden)
1777
+ process.stdout.write(chunk, encoding);
1778
+ callback();
1779
+ },
1780
+ });
1781
+ const rl = createInterface({ input: process.stdin, output, terminal: true });
1782
+ try {
1783
+ const pending = rl.question(label);
1784
+ hidden = true;
1785
+ const answer = await pending;
1786
+ hidden = false;
1787
+ process.stdout.write("\n");
1788
+ return answer;
1789
+ }
1790
+ finally {
1791
+ rl.close();
1792
+ }
1793
+ }
950
1794
  async function loadSettings(root, overrides) {
951
1795
  loadDotEnv(join(root, ".env.local"));
952
1796
  loadDotEnv(join(root, ".env"));
953
1797
  const config = await loadConfig(root);
1798
+ const cliConfig = await readCLIConfig();
954
1799
  const key = overrides.key ?? process.env.GONVEX_PROJECT_KEY ?? process.env.GONVEX_DEPLOY_KEY ?? process.env.GONVEX_KEY ?? "";
955
1800
  const explicitProjectID = overrides.projectID ?? process.env.GONVEX_PROJECT_ID ?? process.env.GONVEX_PROJECT ?? config.project;
1801
+ const overriddenProjectID = overrides.projectID?.trim() || undefined;
1802
+ const keyedProjectID = projectIDFromKey(key);
1803
+ if (overriddenProjectID && keyedProjectID && overriddenProjectID !== keyedProjectID) {
1804
+ throw new Error(`project id ${JSON.stringify(overriddenProjectID)} does not match the project key for ${JSON.stringify(keyedProjectID)}`);
1805
+ }
956
1806
  return {
957
- projectID: overrides.projectID ?? projectIDFromKey(key) ?? explicitProjectID ?? basename(root),
958
- runtimeURL: overrides.runtimeURL ?? process.env.GONVEX_RUNTIME_URL ?? config.runtime ?? defaultRuntimeURL,
1807
+ projectID: overriddenProjectID ?? keyedProjectID ?? (explicitProjectID?.trim() || basename(root)),
1808
+ runtimeURL: normalizeRuntimeURL(overrides.runtimeURL ?? process.env.GONVEX_RUNTIME_URL ?? config.runtime ?? cliConfig.currentRuntime ?? defaultRuntimeURL),
959
1809
  key,
960
1810
  };
961
1811
  }
962
1812
  function projectIDFromKey(key) {
963
1813
  const trimmed = key.trim();
964
- if (!trimmed.startsWith("gvx_"))
1814
+ if (!trimmed.startsWith("gvx_") || trimmed.startsWith("gvx_pat_"))
965
1815
  return undefined;
966
1816
  const payload = trimmed.slice("gvx_".length);
967
1817
  let encodedProject = payload.split(".", 1)[0];
@@ -1042,7 +1892,7 @@ async function copyDir(source, target, overwrite) {
1042
1892
  await mkdir(target, { recursive: true });
1043
1893
  for (const entry of await readdir(source, { withFileTypes: true })) {
1044
1894
  const sourcePath = join(source, entry.name);
1045
- const targetPath = join(target, entry.name);
1895
+ const targetPath = join(target, entry.name === "_gitignore" ? ".gitignore" : entry.name);
1046
1896
  if (entry.isDirectory()) {
1047
1897
  await copyDir(sourcePath, targetPath, overwrite);
1048
1898
  }
@@ -1072,7 +1922,7 @@ async function writeEnvLocal(root, project, runtime) {
1072
1922
  if (existsSync(envPath))
1073
1923
  return;
1074
1924
  const wsURL = runtime.replace(/^http:/, "ws:").replace(/^https:/, "wss:").replace(/\/$/, "") + "/ws";
1075
- await writeFile(envPath, `GONVEX_PROJECT_ID=${project}\nGONVEX_RUNTIME_URL=${runtime}\nGONVEX_PROJECT_KEY=\nVITE_GONVEX_WS_URL=${wsURL}\n`);
1925
+ await writeFile(envPath, `GONVEX_PROJECT_ID=${project}\nGONVEX_RUNTIME_URL=${runtime}\nGONVEX_PROJECT_KEY=\nVITE_GONVEX_PROJECT_ID=${project}\nVITE_GONVEX_URL=${runtime}\nVITE_GONVEX_WS_URL=${wsURL}\n`);
1076
1926
  }
1077
1927
  function templateDir(template) {
1078
1928
  const packageTemplate = resolve(dirname(fileURLToPath(import.meta.url)), "templates", template);
@@ -1085,6 +1935,8 @@ function functionKind(raw) {
1085
1935
  return "internalMutation";
1086
1936
  if (raw === "LiveGrid")
1087
1937
  return "liveGrid";
1938
+ if (raw === "PublicHTTP")
1939
+ return "http";
1088
1940
  return raw.toLowerCase();
1089
1941
  }
1090
1942
  function columnType(kind) {
@@ -1105,6 +1957,29 @@ function valueFor(args, key) {
1105
1957
  return undefined;
1106
1958
  return args[index + 1];
1107
1959
  }
1960
+ function valuesFor(args, key) {
1961
+ const values = [];
1962
+ for (let index = 0; index < args.length; index += 1) {
1963
+ if (args[index] === key && args[index + 1] !== undefined) {
1964
+ values.push(args[index + 1]);
1965
+ index += 1;
1966
+ }
1967
+ }
1968
+ return values;
1969
+ }
1970
+ function positionalArgs(args, optionsWithValues) {
1971
+ const positional = [];
1972
+ for (let index = 0; index < args.length; index += 1) {
1973
+ const arg = args[index];
1974
+ if (optionsWithValues.includes(arg)) {
1975
+ index += 1;
1976
+ continue;
1977
+ }
1978
+ if (!arg.startsWith("-"))
1979
+ positional.push(arg);
1980
+ }
1981
+ return positional;
1982
+ }
1108
1983
  function basename(path) {
1109
1984
  const parts = path.split(/[\\/]/).filter(Boolean);
1110
1985
  return parts.at(-1) ?? "app";
@@ -1119,11 +1994,31 @@ function sleep(ms, signal) {
1119
1994
  });
1120
1995
  }
1121
1996
  function printHelp() {
1122
- console.log("Usage: gonvex <dev|init|create|env> [options]");
1997
+ console.log("Usage: gonvex <dev|init|create|env|auth|login|logout|whoami|project|token> [options]");
1123
1998
  console.log(" gonvex dev [--project <path>] [--runtime-url <url>] [--project-id <id>] [--key <key>] [--once] [--verbose-logs] [-- <command>]");
1124
1999
  console.log(" gonvex init [--template vite-react] [--project <id>] [--runtime <url>]");
1125
- console.log(" gonvex create <app-name> [--template vite-react]");
2000
+ console.log(" gonvex create <app-name> [--runtime-url <url>] [--provision] [--database-mode single|multiTenant] [--google-auth] [--origin <url>]... [--signup-mode personal|inviteOnly] [--owner <email>]");
1126
2001
  console.log(" gonvex env <list|get|set|push|remove> [--project <path>] [--runtime-url <url>] [--project-id <id>] [--key <key>]");
2002
+ console.log(" gonvex auth <add|remove|status|users> [google] [options]");
2003
+ console.log(" gonvex login [--runtime-url <url>] [--email <email> | --token <personal-access-token>]");
2004
+ console.log(" gonvex logout [--runtime-url <url>]");
2005
+ console.log(" gonvex whoami [--runtime-url <url>] [--json]");
2006
+ console.log(" gonvex project <list|create|select> [options]");
2007
+ console.log(" gonvex token <list|create|revoke|permissions> [options]");
2008
+ }
2009
+ function printAuthHelp() {
2010
+ console.log("Usage: gonvex auth <command> [options]");
2011
+ console.log(" gonvex auth add google [--origin URL]... [--callback-path /] [--signup-mode personal|inviteOnly]");
2012
+ console.log(" gonvex auth remove google [--origin URL]... [--callback-path /]");
2013
+ console.log(" gonvex auth status [--json]");
2014
+ console.log(" gonvex auth doctor [--json]");
2015
+ console.log(" gonvex auth users [--json]");
2016
+ console.log(" gonvex auth tenants list [--json]");
2017
+ console.log(" gonvex auth tenants create <name> [--owner <email>]");
2018
+ console.log(" gonvex auth memberships list --tenant <tenant-id> [--json]");
2019
+ console.log(" gonvex auth memberships add --tenant <tenant-id> --email <email> [--role member]");
2020
+ console.log(" gonvex auth memberships remove --tenant <tenant-id> (--user <user-id> | --email <invited-email>)");
2021
+ console.log(" gonvex auth user <disable|enable|delete> <user-id>");
1127
2022
  }
1128
2023
  function printEnvHelp() {
1129
2024
  console.log("Usage: gonvex env <command> [options]");
@@ -1135,6 +2030,19 @@ function printEnvHelp() {
1135
2030
  console.log(" gonvex env push --file FILE");
1136
2031
  console.log(" gonvex env remove NAME");
1137
2032
  }
2033
+ function printProjectHelp() {
2034
+ console.log("Usage: gonvex project <command> [options]");
2035
+ console.log(" gonvex project list [--runtime-url <url>] [--json]");
2036
+ console.log(" gonvex project create [name] [--runtime-url <url>] [--database-mode single|multiTenant] [--project-root <path>] [--json]");
2037
+ console.log(" gonvex project select <project-id> [--runtime-url <url>] [--project-root <path>]");
2038
+ }
2039
+ function printTokenHelp() {
2040
+ console.log("Usage: gonvex token <command> [options]");
2041
+ console.log(" gonvex token list [--runtime-url <url>] [--json]");
2042
+ console.log(" gonvex token create [name] [--permission <permission>]... [--full] [--expires-at <RFC3339>] [--json]");
2043
+ console.log(" gonvex token revoke <token-id> [--runtime-url <url>]");
2044
+ console.log(" gonvex token permissions");
2045
+ }
1138
2046
  function isCliEntrypoint() {
1139
2047
  const invokedPath = process.argv[1];
1140
2048
  if (!invokedPath)