diff --git a/README.md b/README.md index c741ea577..889afc89b 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ npm install -D void npx void init ``` -This scaffolds a starter app with an interactive setup flow. Then: +This scaffolds a starter app with an interactive setup flow and can also log you in and link or create your deploy project. Then: ```sh npm run dev # local dev with workerd runtime diff --git a/docs/guide/deployment.md b/docs/guide/deployment.md index c57f40fa8..7857b527b 100644 --- a/docs/guide/deployment.md +++ b/docs/guide/deployment.md @@ -11,11 +11,11 @@ Void apps run on Cloudflare's infrastructure but you don't need to use your own ### First deploy ```bash -void auth login # OAuth via GitHub or Google +void init # can handle auth + project setup during onboarding void deploy # auto-detects app type, builds, deploys ``` -On first deploy, the CLI prompts you to create or select a project. The project link is saved to `.void/project.json` for subsequent deploys. +If you skipped Void project setup during `void init`, you can still run `void auth login` manually. On first deploy, the CLI will prompt you to create or select a project if none is linked yet. The project link is saved to `.void/project.json` for subsequent deploys. ### Migrations diff --git a/docs/guide/quickstart.md b/docs/guide/quickstart.md index cb4e3cfc1..a7379e060 100644 --- a/docs/guide/quickstart.md +++ b/docs/guide/quickstart.md @@ -34,6 +34,10 @@ This scaffolds a Pages-mode starter with an interactive setup flow. As part of `void init`, you'll choose a Pages framework (React, Vue, Svelte, or Solid) and a starter type. D1 is the default top option and scaffolds a DB-backed page loader, schema, migration, and API route. PostgreSQL scaffolds the same starter and writes `"database": "pg"` to `void.json`. Static Pages skips the database and server starter files so you can start with static content and add Void features later. +At the end of the full interactive flow, `void init` can also handle Void project setup by logging you in and linking or creating your Void project. That means the default first-time path is just `void init`, then `void deploy`. + +For the database-backed starters, D1 is the zero-config default for prototyping and read-heavy apps, while PostgreSQL is better when you already have Postgres infrastructure or need heavier writes and more complex queries. +
💡 Notes on void binary usage @@ -141,12 +145,14 @@ Then visit: - App: `http://localhost:5173` - API route: `http://localhost:5173/api/hello` -### 3. Log in +### 3. Finish Void project setup if you skipped it during `void init` ```sh void auth login ``` +If you already logged in and linked a project during `void init`, you can skip this step. + ### 4. Deploy Set secrets once before deploying, if any: @@ -189,7 +195,7 @@ void deploy On first deploy, Void will: - build your app -- create or link a project +- create or link a project if you did not already do that during `void init` - provision required resources (for example D1/KV/R2 when inferred) - deploy to `https://.void.app` diff --git a/docs/reference/cli.md b/docs/reference/cli.md index caf00216a..43baea17e 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -58,6 +58,7 @@ After that, the full interactive flow walks through: 5. **MCP config:** writes MCP server config using that same agent context. 6. **Demo code:** for existing non-Pages projects, optionally scaffolds a `db/migrations/` directory plus an API route and typed fetch example. 7. **GitHub Actions:** optionally creates `.github/workflows/deploy.yml` with the right package manager commands. +8. **Project setup:** optionally logs you in, lets you select or create a project, and writes `.void/project.json` so your first deploy can just be `void deploy`. If no agent is detected, `void init` asks you to choose one from a short list (Claude, Cursor, Codex, Gemini CLI, Generic). That single choice is reused across all agent steps. @@ -77,6 +78,8 @@ Flags can be combined. When any flag is provided, only the specified steps run a OAuth login. You choose GitHub or Google at the prompt, and the token is saved to `~/.void/config.json`. +This is optional if you already completed auth during the interactive `void init` flow. + ### `void auth logout` Removes saved credentials. @@ -168,6 +171,8 @@ Project resolution precedence: If no project is linked and no override is provided, CLI prompts to link or create one. +That fallback is mainly for projects that skipped Void project setup during `void init`. + ## Database ### `void db status` diff --git a/packages/void/AGENT_PROMPT.md b/packages/void/AGENT_PROMPT.md index 08746c651..83fffa6fa 100644 --- a/packages/void/AGENT_PROMPT.md +++ b/packages/void/AGENT_PROMPT.md @@ -1,6 +1,6 @@ ## Void -This project uses [Void](https://void.cloud) — a fullstack Vite plugin + deployment platform for Cloudflare. `voidPlugin()` in `vite.config.ts` gives you file-based API routing on Hono (`routes/`), Inertia-inspired server-rendered pages with co-located loaders/actions (`pages/` + `@void/vue` or `@void/react`), auto-provisioned D1/KV/R2 bindings, first-class Drizzle ORM integration (schema in `db/schema.ts` -> `void/db` Drizzle instance -> typed routes -> typed fetch client), built-in auth, queues, cron jobs, edge caching (ISR), and one-command deploys via `npx void deploy`. +This project uses [Void](https://void.cloud) — a fullstack Vite plugin + deployment platform for Cloudflare. `voidPlugin()` in `vite.config.ts` gives you file-based API routing on Hono (`routes/`), Inertia-inspired server-rendered pages with co-located loaders/actions (`pages/` + `@void/vue` or `@void/react`), auto-provisioned D1/KV/R2 bindings, first-class Drizzle ORM integration (schema in `db/schema.ts` -> `void/db` Drizzle instance -> typed routes -> typed fetch client), built-in auth, queues, cron jobs, edge caching (ISR), and one-command deploys via `npx void deploy`. For first-time setup, prefer `npx void init`; the interactive flow can scaffold the app, configure project files, handle auth, and link or create the deploy project before the first deploy. Database: define Drizzle tables in `db/schema.ts`, import `db` from `void/db` and tables from `@schema`. Use `void db push` for prototyping, `void db generate` for production migrations. `drizzle-orm` and `drizzle-kit` ship with void (no extra install). Migrations live in `db/migrations/`. diff --git a/packages/void/README.md b/packages/void/README.md index c02d86bf5..787f6367e 100644 --- a/packages/void/README.md +++ b/packages/void/README.md @@ -19,6 +19,8 @@ npm install -D void npx void init ``` +The full interactive `void init` flow can scaffold a starter, configure local project files, log you in, and link or create your Void project so the next step is just `npx void deploy`. + ```ts // vite.config.ts import { defineConfig } from "vite"; diff --git a/packages/void/getting-started-prompt.txt b/packages/void/getting-started-prompt.txt index b95775f92..61fd5644b 100644 --- a/packages/void/getting-started-prompt.txt +++ b/packages/void/getting-started-prompt.txt @@ -5,6 +5,10 @@ npx void init During `void init`, first choose a Pages framework (React, Vue, Svelte, or Solid), then choose D1 for a zero-config, fully managed default or PostgreSQL if you already have Postgres infrastructure or expect heavier writes and more complex queries. +At the end of the interactive flow, `void init` can also log you in and link or create your Void project so your first deploy is just: + +void deploy + Database workflow: define Drizzle tables in `db/schema.ts`, import `db` from `void/db` and tables from `@schema`. Use `void db push` to prototype (applies schema directly, no migration files). Use `void db generate` when ready to create production migration files in `db/migrations/`. `drizzle-orm` and `drizzle-kit` ship with void — no extra install needed. Scaffold new tables with `void gen model [columns...]`. Now ask me what I want to build. diff --git a/packages/void/skills/void/SKILL.md b/packages/void/skills/void/SKILL.md index 0cd0e52d9..673084de9 100644 --- a/packages/void/skills/void/SKILL.md +++ b/packages/void/skills/void/SKILL.md @@ -13,9 +13,7 @@ Docs in this skill are bundled from `docs/` during `void` package build and live ## Command Naming -Use `void` in examples and commands in this skill. - -- `void` and `void` are equivalent binaries. Prefer `void`. +Use `void` in examples and commands in this skill. For first-time setup, prefer `void init` followed by `void deploy`; `void init` can handle auth and project linking interactively. ## No-Args Behavior diff --git a/packages/void/src/cli/deploy-setup.ts b/packages/void/src/cli/deploy-setup.ts new file mode 100644 index 000000000..6c1fe7f5e --- /dev/null +++ b/packages/void/src/cli/deploy-setup.ts @@ -0,0 +1,100 @@ +import { cancel, confirm, isCancel, log, select, spinner } from "@clack/prompts"; +import { PlatformClient } from "./client"; +import { getToken, isStagingMode, runLogin, saveToken } from "./auth"; +import { promptProjectSelection, promptProjectSlug } from "./prompt"; +import { inferDefaultSlug } from "./resolve-project"; +import { readProjectConfig, writeProjectConfig, type ProjectConfig } from "./project"; + +async function promptForLoginToken(): Promise { + const provider = await select({ + message: "Choose a login provider:", + options: [ + { value: "github", label: "GitHub" }, + { value: "google", label: "Google" }, + ], + }); + + if (isCancel(provider)) { + cancel("Setup cancelled."); + process.exit(0); + } + + const providerName = provider === "github" ? "GitHub" : "Google"; + const s = spinner(); + s.start(`Opening browser for ${providerName} login...`); + + try { + const token = await runLogin(provider); + saveToken(token); + s.stop("Logged in!"); + log.success(isStagingMode() ? "Staging token saved." : "Token saved."); + return token; + } catch (error) { + s.stop("Login failed"); + throw error; + } +} + +async function ensureToken(): Promise { + const existing = getToken(); + if (existing) { + return existing; + } + + return promptForLoginToken(); +} + +async function createProject( + root: string, + client: PlatformClient, + slug: string, +): Promise { + log.step(`Creating project "${slug}"...`); + const project = await client.createProject(slug); + const config = { projectId: project.id, slug: project.slug }; + writeProjectConfig(root, config); + log.success(`Project created and linked: ${config.slug}`); + return config; +} + +export async function maybeSetupDeployProject(root: string): Promise { + const existing = readProjectConfig(root); + if (existing) { + log.info(`Project already linked: ${existing.slug}`); + return existing; + } + + const existingToken = getToken(); + const shouldSetup = await confirm({ + message: existingToken + ? "Link your Void project now?" + : "Set up your Void project now? (log in + link project)", + }); + if (isCancel(shouldSetup)) { + cancel("Setup cancelled."); + process.exit(0); + } + if (!shouldSetup) { + return null; + } + + const token = existingToken ?? (await ensureToken()); + const client = new PlatformClient(token); + const projects = await client.listProjects(); + + if (projects.length === 0) { + const slug = await promptProjectSlug(inferDefaultSlug(root)); + return createProject(root, client, slug); + } + + const result = await promptProjectSelection(projects, "Select a project to link:"); + if (result.action === "select") { + const config = { projectId: result.project.id, slug: result.project.slug }; + writeProjectConfig(root, config); + log.success(`Linked project: ${config.slug}`); + return config; + } + + const slug = await promptProjectSlug(inferDefaultSlug(root)); + return createProject(root, client, slug); +} diff --git a/packages/void/src/cli/init.ts b/packages/void/src/cli/init.ts index 43fc0593a..38443b718 100644 --- a/packages/void/src/cli/init.ts +++ b/packages/void/src/cli/init.ts @@ -30,6 +30,7 @@ import { type McpConfigTarget, } from "./agents"; import { cliTitle } from "./output"; +import { maybeSetupDeployProject } from "./deploy-setup"; export interface InitArgs { tsconfig: boolean; @@ -1201,5 +1202,9 @@ export async function runInitCommand(root: string, initArgs?: InitArgs): Promise } } + if (!targeted) { + await maybeSetupDeployProject(root); + } + outro(`Run \`${getDevCommand(root, framework)}\` to start developing`); } diff --git a/packages/void/test/unit/init.test.ts b/packages/void/test/unit/init.test.ts index c795bc344..ee6e7f005 100644 --- a/packages/void/test/unit/init.test.ts +++ b/packages/void/test/unit/init.test.ts @@ -1,11 +1,18 @@ import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { afterEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { isScaffoldableEmptyProject, runInitCommand } from "../../src/cli/init"; const mockSelect = vi.fn(); const mockConfirm = vi.fn(); +const mockText = vi.fn(); +const mockGetToken = vi.fn(); +const mockRunLogin = vi.fn(); +const mockSaveToken = vi.fn(); +const mockIsStagingMode = vi.fn(); +const mockListProjects = vi.fn(); +const mockCreateProject = vi.fn(); vi.mock("@clack/prompts", () => ({ intro: vi.fn(), @@ -14,10 +21,32 @@ vi.mock("@clack/prompts", () => ({ log: { success: vi.fn(), warn: vi.fn(), error: vi.fn(), info: vi.fn(), step: vi.fn() }, confirm: (...args: unknown[]) => mockConfirm(...args), select: (...args: unknown[]) => mockSelect(...args), + text: (...args: unknown[]) => mockText(...args), + spinner: () => ({ start: vi.fn(), stop: vi.fn(), message: vi.fn() }), isCancel: (value: unknown) => value === Symbol.for("cancel"), cancel: vi.fn(), })); +vi.mock("../../src/cli/auth", () => ({ + getToken: (...args: unknown[]) => mockGetToken(...args), + runLogin: (...args: unknown[]) => mockRunLogin(...args), + saveToken: (...args: unknown[]) => mockSaveToken(...args), + isStagingMode: (...args: unknown[]) => mockIsStagingMode(...args), + getSiteDomain: () => "void.app", +})); + +vi.mock("../../src/cli/client", () => ({ + PlatformClient: class { + listProjects(...args: unknown[]) { + return mockListProjects(...args); + } + + createProject(...args: unknown[]) { + return mockCreateProject(...args); + } + }, +})); + function withTempDir(run: (root: string) => void): void { const root = mkdtempSync(join(tmpdir(), "void-init-")); try { @@ -30,6 +59,21 @@ function withTempDir(run: (root: string) => void): void { afterEach(() => { mockSelect.mockReset(); mockConfirm.mockReset(); + mockText.mockReset(); + mockGetToken.mockReset(); + mockRunLogin.mockReset(); + mockSaveToken.mockReset(); + mockIsStagingMode.mockReset(); + mockListProjects.mockReset(); + mockCreateProject.mockReset(); +}); + +beforeEach(() => { + mockGetToken.mockReturnValue(null); + mockRunLogin.mockResolvedValue("token_123"); + mockIsStagingMode.mockReturnValue(false); + mockListProjects.mockResolvedValue([]); + mockCreateProject.mockResolvedValue({ id: "proj_123", slug: "my-app" }); }); describe("isScaffoldableEmptyProject", () => { @@ -96,7 +140,7 @@ describe("isScaffoldableEmptyProject", () => { .mockResolvedValueOnce("react") .mockResolvedValueOnce("sqlite") .mockResolvedValueOnce("generic"); - mockConfirm.mockResolvedValueOnce(false); + mockConfirm.mockResolvedValueOnce(false).mockResolvedValueOnce(false); await runInitCommand(root); @@ -128,10 +172,12 @@ describe("isScaffoldableEmptyProject", () => { "CREATE TABLE IF NOT EXISTS messages", ); - expect(() => readFileSync(join(root, "routes", "api", "hello.get.ts"), "utf-8")).toThrow(); - expect(() => readFileSync(join(root, "index.html"), "utf-8")).toThrow(); - expect(() => readFileSync(join(root, "src", "main.ts"), "utf-8")).toThrow(); - expect(() => readFileSync(join(root, "void.json"), "utf-8")).toThrow(); + expect(() => readFileSync(join(root, "routes", "api", "hello.get.ts"), "utf-8")).toThrow( + /ENOENT/, + ); + expect(() => readFileSync(join(root, "index.html"), "utf-8")).toThrow(/ENOENT/); + expect(() => readFileSync(join(root, "src", "main.ts"), "utf-8")).toThrow(/ENOENT/); + expect(() => readFileSync(join(root, "void.json"), "utf-8")).toThrow(/ENOENT/); const tsconfig = JSON.parse(readFileSync(join(root, "tsconfig.json"), "utf-8")); expect(tsconfig.compilerOptions.jsx).toBe("react-jsx"); @@ -149,7 +195,7 @@ describe("isScaffoldableEmptyProject", () => { .mockResolvedValueOnce("vue") .mockResolvedValueOnce("postgresql") .mockResolvedValueOnce("generic"); - mockConfirm.mockResolvedValueOnce(false); + mockConfirm.mockResolvedValueOnce(false).mockResolvedValueOnce(false); await runInitCommand(root); @@ -261,11 +307,14 @@ describe("isScaffoldableEmptyProject", () => { try { writeFileSync(join(root, "void.json"), JSON.stringify({ database: "pg" }, null, 2) + "\n"); mockSelect.mockResolvedValueOnce("sqlite").mockResolvedValueOnce("generic"); - mockConfirm.mockResolvedValueOnce(false).mockResolvedValueOnce(false); + mockConfirm + .mockResolvedValueOnce(false) + .mockResolvedValueOnce(false) + .mockResolvedValueOnce(false); await runInitCommand(root); - expect(() => readFileSync(join(root, "void.json"), "utf-8")).toThrow(); + expect(() => readFileSync(join(root, "void.json"), "utf-8")).toThrow(/ENOENT/); } finally { rmSync(root, { recursive: true, force: true }); } @@ -288,7 +337,10 @@ describe("isScaffoldableEmptyProject", () => { ) + "\n", ); mockSelect.mockResolvedValueOnce("sqlite").mockResolvedValueOnce("generic"); - mockConfirm.mockResolvedValueOnce(false).mockResolvedValueOnce(false); + mockConfirm + .mockResolvedValueOnce(false) + .mockResolvedValueOnce(false) + .mockResolvedValueOnce(false); await runInitCommand(root); @@ -301,7 +353,6 @@ describe("isScaffoldableEmptyProject", () => { rmSync(root, { recursive: true, force: true }); } }); - it("removes the pg config when choosing no database yet", async () => { const root = mkdtempSync(join(tmpdir(), "void-init-")); try { @@ -317,6 +368,74 @@ describe("isScaffoldableEmptyProject", () => { } }); + it("can log in and create a project during init", async () => { + const root = mkdtempSync(join(tmpdir(), "void-init-")); + try { + mockSelect + .mockResolvedValueOnce("react") + .mockResolvedValueOnce("sqlite") + .mockResolvedValueOnce("generic") + .mockResolvedValueOnce("github"); + mockConfirm.mockResolvedValueOnce(false).mockResolvedValueOnce(true); + mockText.mockResolvedValueOnce("my-live-app"); + mockCreateProject.mockResolvedValueOnce({ id: "proj_live", slug: "my-live-app" }); + + await runInitCommand(root); + + expect(mockConfirm.mock.calls[1]?.[0]).toMatchObject({ + message: "Set up your Void project now? (log in + link project)", + }); + expect(mockRunLogin).toHaveBeenCalledWith("github"); + expect(mockSaveToken).toHaveBeenCalledWith("token_123"); + expect(mockListProjects).toHaveBeenCalledOnce(); + expect(mockCreateProject).toHaveBeenCalledWith("my-live-app"); + expect(JSON.parse(readFileSync(join(root, ".void", "project.json"), "utf-8"))).toEqual({ + projectId: "proj_live", + slug: "my-live-app", + }); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + it("can link an existing project during init without re-authenticating", async () => { + const root = mkdtempSync(join(tmpdir(), "void-init-")); + try { + mockGetToken.mockReturnValue("existing_token"); + mockListProjects.mockResolvedValueOnce([ + { + id: "proj_456", + user_id: "user_1", + slug: "existing-app", + type: "worker", + created_at: "2026-03-27T00:00:00.000Z", + custom_domain: null, + }, + ]); + mockSelect + .mockResolvedValueOnce("react") + .mockResolvedValueOnce("sqlite") + .mockResolvedValueOnce("generic") + .mockResolvedValueOnce("proj_456"); + mockConfirm.mockResolvedValueOnce(false).mockResolvedValueOnce(true); + + await runInitCommand(root); + + expect(mockConfirm.mock.calls[1]?.[0]).toMatchObject({ + message: "Link your Void project now?", + }); + expect(mockRunLogin).not.toHaveBeenCalled(); + expect(mockSaveToken).not.toHaveBeenCalled(); + expect(mockCreateProject).not.toHaveBeenCalled(); + expect(JSON.parse(readFileSync(join(root, ".void", "project.json"), "utf-8"))).toEqual({ + projectId: "proj_456", + slug: "existing-app", + }); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + it("defaults the database prompt to D1 when no database is configured", async () => { const root = mkdtempSync(join(tmpdir(), "void-init-")); try {