diff --git a/packages/helpers/create-bsv-app/.claude/skills/scaffolding-bsv-apps/SKILL.md b/packages/helpers/create-bsv-app/.claude/skills/scaffolding-bsv-apps/SKILL.md new file mode 100644 index 000000000..9c645cc2e --- /dev/null +++ b/packages/helpers/create-bsv-app/.claude/skills/scaffolding-bsv-apps/SKILL.md @@ -0,0 +1,102 @@ +--- +name: scaffolding-bsv-apps +description: Use when scaffolding a new BSV-enabled web app, or adding BSV capabilities (wallet connection, passwordless wallet login, per-request signed auth) to an existing project — i.e. anything involving the create-bsv-app CLI / `npm create bsv-app`. +--- + +# Scaffolding BSV apps with create-bsv-app + +## Overview + +`create-bsv-app` scaffolds web apps with the blockchain *invisible*: it delegates the base project to the official generators (Vite for React, a lean Express skeleton) and layers in **capabilities** — small, role-aware helper files built on the BSV abstraction libs (`@bsv/auth`, `@bsv/sdk`, `@bsv/wallet-relay`) — plus a generated `AGENTS.md` documenting each. + +**One flow:** every input *door* produces a single `ProjectConfig`, which one pipeline applies. So the fastest, deterministic path (great for agents) is the `--file` door. + +## When to use + +- "Scaffold/create a BSV (Bitcoin SV) app", "add wallet login", "passwordless login with a wallet", "sign API requests with a wallet". +- Adding `wallet-connect` / `wallet-login` / `signed-requests` to a project. +- Not for: non-BSV scaffolding, or hand-writing `@bsv/*` integrations from scratch (this tool generates them). + +## Fastest path (agents): the `--file` door + +Write a `ProjectConfig` JSON and run it — no prompts, fully deterministic: + +```bash +npx create-bsv-app --dir my-app --file config.json +``` + +```jsonc +{ + "mode": "new", // "new" | "add" (see Modes) + "name": "my-app", + "stack": { // a new project needs at least one of these: + "frontend": { "framework": "react", "variant": "react-ts" }, + "backend": { "framework": "express" } + }, + "bsvDir": "src/bsv", // where helpers are written (default) + "capabilities": ["wallet-login"], // see table; requires are expanded in new mode + "glue": true, // auto-wire base files (new mode only) + "packageManager": "npm", // npm | pnpm | yarn | bun + "network": "test" // test | main +} +``` + +Unspecified fields default (`dir`→`.`, `bsvDir`→`src/bsv`, `glue`→`false` unless omitted in schema, `packageManager`→`npm`, `network`→`test`). + +## Other doors + +- **Flags + `--yes`** (non-interactive without a file): `npx create-bsv-app --dir my-app --name my-app --frontend react --backend express --capabilities wallet-login --yes` +- **Interactive**: `npx create-bsv-app` (schema-driven prompts). +- **`--ui`**: `npx create-bsv-app --ui` — opens a local page; **Generate** posts the draft to an ephemeral local server that runs the *same* pipeline. The browser never writes files. + +## Flags + +| Flag | Notes | +| --- | --- | +| `--dir ` | Target dir (also positional). Default `.`. | +| `--file ` | Read a complete `ProjectConfig` JSON; skips prompts. | +| `--yes` | Resolve from flags (+ existing manifest), no prompts. | +| `--mode ` | Force mode. On the `--file` door this **overrides** the file's `"mode"`. | +| `--name`, `--frontend `, `--backend `, `--variant` | New-project stack. | +| `--bsv-dir ` | Helper dir (default `src/bsv`). | +| `--capabilities ` | Comma-separated capability ids. | +| `--package-manager`, `--network ` | Defaults npm / test. | +| `--glue` / `--no-glue` | New mode: `--no-glue` skips auto-wiring base files (`AGENTS.md` then prints the snippets to paste). | +| `--ui` | Browser door. | + +## Capabilities + +| id | What it adds | +| --- | --- | +| `wallet-connect` | **Base** (auto-included for new projects). Connect any BRC-100 wallet (desktop or mobile/relay), app-wide React context, the `@bsv/auth` proof primitive, and a baseline `GET /api/identity` route. | +| `wallet-login` | Passwordless login: wallet signs a proof, server verifies it → trusted `identityKey`. Adds `/login` page + `/api/login`. Requires `wallet-connect`. | +| `signed-requests` | Per-call auth: sign a proof bound to `{ action, body }`, verify server-side. Adds `/signed-demo` + `/api/echo`. Requires `wallet-connect`. | + +## Modes + +- **new** — empty target dir; runs the base generator(s), assembles wired base files, installs capabilities, writes the manifest + `AGENTS.md`. A lone `.git` or `bsv-scaffold.json` does **not** count as non-empty. +- **add** — existing project; installs exactly the selected capabilities and updates the manifest. No base generator. +- **Inference (no `--mode`):** a `bsv-scaffold.json` in the dir → `add`; otherwise `new`. +- **add via `--file`:** put `"mode": "add"` in the JSON, *or* pass `--mode add` (overrides the file). Passing the recorded `bsv-scaffold.json` as `--file` reproduces (`new`) — which expects an effectively empty dir (see *new* above), so in an already-populated project it would hit the empty-dir check; use `--mode add` there to re-apply into it. +- **new-mode floor:** new always includes the `defaultSelected` baseline (`wallet-connect`) even if `capabilities: []`. + +## How the auth actually works (so you can explain results) + +Client connects a wallet → fetches the server's identity key from `GET /api/identity` (via `getServerIdentity()`, the proof `counterparty`) → signs a proof bound to `{ counterparty, action, body? }` → POSTs it. The server verifies the signature with its `serverWallet` + a single-use nonce, yielding a cryptographically trusted `identityKey`. No password, no shared secret. Sessions (JWT, etc.) are **documented, not scaffolded** — see the generated `AGENTS.md`. + +## Layout & install + +A frontend **and** backend → a monorepo of **independent packages**: `client/` and `server/` each have their own `package.json` / lockfile (no root workspace). Install each: `cd client && npm i`, `cd server && npm i`. In dev they're cross-origin (Vite `:5173` → Express `:3000`), so the server enables CORS and the client reads `API_BASE_URL` from `client/src/bsv/config.ts` (`VITE_API_URL`, default `http://localhost:3000`). Each `config.ts` **reads from the environment with dev fallbacks** — you don't hardcode values in it. Set client vars in `client/.env` (Vite auto-loads `VITE_`-prefixed vars); set server vars (`SERVER_PRIVATE_KEY`, `PORT`, `CLIENT_ORIGIN`, read in `server/src/bsv/config.ts`) via your runtime env / a `.env` loader. Unset server key → a random dev key (identity changes per restart). + +After any run, **read the generated `AGENTS.md`** — it documents each installed capability's API and wiring. + +## Adding a new capability (contributing to the tool) + +A capability is one file in `src/capabilities/.ts` exporting a `Capability` (see `src/types.ts`), registered in `src/registry.ts`. Full step-by-step is in the package README's **"Adding a capability"** section. Essentials: `id`/`title`/`description`, `roles` (`shared`/`client`/`server` → placement), optional `requires`, `files(ctx)` (use `bsvImport(ctx, name)` for cross-file imports so non-default `--bsv-dir` resolves), optional `baseEdits({ builder, ctx })` for new-project glue (route descriptors `{ path, component, importPath, label }`), `npmDependencies(ctx)`, `agentsSection(ctx)`. Then `npx jest && npm run lint:ci && npm run build`. + +## Common mistakes + +- **Forgetting a target in new mode** — a `new` config must declare a frontend or backend, else it errors. +- **Expecting the manifest to carry `mode`** — `bsv-scaffold.json` has no `mode` field; passing it to `--file` defaults to `new` (reproduce). Use `--mode add` to re-apply. +- **One `npm i` at the root of a monorepo** — there's no root workspace; install in `client/` and `server/` separately. +- **Hardcoding the server identity key** — don't; the client fetches it from `/api/identity` automatically. diff --git a/packages/helpers/create-bsv-app/.gitignore b/packages/helpers/create-bsv-app/.gitignore new file mode 100644 index 000000000..cda7b2850 --- /dev/null +++ b/packages/helpers/create-bsv-app/.gitignore @@ -0,0 +1,4 @@ +node_modules +dist +coverage +*.tsbuildinfo diff --git a/packages/helpers/create-bsv-app/README.md b/packages/helpers/create-bsv-app/README.md new file mode 100644 index 000000000..6f5062e0e --- /dev/null +++ b/packages/helpers/create-bsv-app/README.md @@ -0,0 +1,212 @@ +# create-bsv-app + +Scaffold BSV-enabled apps, or add BSV capabilities to an existing project — without writing key- or transaction-handling code yourself. The tool delegates base-project creation to the official generators (e.g. Vite for React, a lean Express skeleton for the server) and layers in **capabilities**: small, role-aware utility files built on the BSV abstraction libraries, plus an `AGENTS.md` contract describing how to use them. + +A new project runs end-to-end on `npm run dev` straight away: the base `main.tsx`/`App.tsx`/server entry are **wired automatically** so you get a working wallet flow — a desktop-wallet-first connect that, on failure, opens a modal offering *Connect with a mobile wallet* (relay QR) or *Install a desktop wallet* ([desktop.bsvb.tech](https://desktop.bsvb.tech)) — plus a routed `/login` page once you add `wallet-login`. Routing uses `react-router-dom`; the `Home` page exposes the connect button and each capability contributes its own page + route. + +## One command + +```bash +npx create-bsv-app +``` + +Every run resolves a single `ProjectConfig` and feeds it through one pipeline. There are four ways ("doors") to produce that config — they differ only in *how* the config is gathered: + +1. **Interactive CLI** — `npx create-bsv-app` with no `--yes` prompts you through the schema (mode, stack, capabilities, …). +2. **Flags** — pass everything on the command line and add `--yes` to skip prompts entirely. +3. **`--file `** — supply a complete config as JSON and skip prompts. Best for automation and AI agents. +4. **`--ui`** — `npx create-bsv-app --ui` opens a local page (sectioned accordions over the same schema). Fill it in, press **Generate**, and the project is scaffolded by the same pipeline; the page also shows the equivalent command. The server is local-only (`127.0.0.1`), single-use, and closes once the project is generated. + +## Modes + +The config carries a `mode`: + +- **`new`** — scaffold a brand-new project. The target directory must be empty. Runs the base generator(s), then installs the selected capabilities and writes the manifest + `AGENTS.md`. +- **`add`** — add capabilities to an existing project. No base generator runs; capability files are placed and the manifest is updated. + +When you don't pass `--mode`, the tool infers it: if a `bsv-scaffold.json` manifest already exists in the target directory, it defaults to `add` (re-using the existing stack); otherwise `new`. + +## Flags + +| Flag | Applies to | Description | +| --- | --- | --- | +| `--dir ` | both | Target directory (also accepted as a positional arg). Defaults to `.`. | +| `--file ` | both | Read a complete config from a JSON file; skips all prompts. | +| `--yes` | both | Non-interactive: resolve the config from flags (+ existing manifest) without prompting. | +| `--force` | add | Overwrite capability utility files that already exist (glue files and `AGENTS.md` are always rewritten). | +| `--mode ` | both | Force the mode instead of inferring it. | +| `--name ` | new | Project name. | +| `--frontend ` | new | Frontend framework. `react` currently scaffolds via Vite. | +| `--backend ` | new | Backend framework. | +| `--variant ` | new | Frontend template variant (default `react-ts`). | +| `--bsv-dir ` | both | Where capability files are written (default `src/bsv`). | +| `--capabilities ` | both | Comma-separated capability ids to install. | +| `--package-manager ` | new | Package manager for the base generators (default `npm`). | +| `--network ` | new | BSV network the capabilities target (default `test`). | +| `--glue` | both | Also emit optional "glue" files (e.g. example wiring) for the capabilities. | +| `--no-glue` | new | Skip auto-wiring the base files (`main.tsx` provider wrap, `App.tsx` routes, server routes). The context/helper/page files are still generated, and `AGENTS.md` prints the exact wiring snippets to paste yourself. | +| `--ui` | both | Open the HTML accordion page in a browser and scaffold on Generate (local single-use server). | + +A frontend + backend together produce a **monorepo** layout: `client/` and `server/` are **independent packages** — each has its own `package.json`, `node_modules`, and lockfile, with no root workspace stitching them together. Install and run each app in its own directory (`cd client && npm i`, `cd server && npm i`); neither can resolve the other's dependencies, and each deploys on its own. A single target scaffolds at the root. Shared capability files are duplicated into each target that needs them. + +### Capabilities + +| id | Description | +| --- | --- | +| `wallet-connect` | Base (auto-selected for new projects): connect any BRC-100 wallet — desktop or mobile/relay — and use it app-wide via React context, plus the `@bsv/auth` proof primitive. | +| `wallet-login` | Passwordless login — a signed proof (`action: 'login'`) verified server-side. Builds on `wallet-connect`. | +| `signed-requests` | Per-call authentication — sign API requests bound to a route + body; verify with a framework-agnostic function. Builds on `wallet-connect`. | + +New projects include `wallet-connect` by default (with the React contexts always generated); pass `--no-glue` to skip the automatic base-file wiring (the generated `AGENTS.md` then lists the snippets to paste). In `add` mode the base files are never touched — `AGENTS.md` always carries the manual wiring snippets. + +#### Configuration & environment + +Each target keeps its environment in a single `bsv/config.ts` instead of scattering `process.env` / `import.meta.env` reads (or hard-coded keys) across files: + +- **`client/src/bsv/config.ts`** — `API_BASE_URL` (from `VITE_API_URL`, default `http://localhost:3000`). Every client fetch helper (`getServerIdentity`, login, signed requests) targets it. Vite loads `VITE_`-prefixed vars from `client/.env`; set `VITE_API_URL` when the client is served from a different origin than the API in production. +- **`server/src/bsv/config.ts`** — `SERVER_PRIVATE_KEY` (the verify-only `serverWallet`'s key; a random dev fallback is used if unset, so the server's identity changes per restart — set it for a stable identity), `PORT`, and `CLIENT_ORIGIN` (the browser origin allowed by CORS, default `http://localhost:5173`). + +```bash +# server/.env (loaded by your process manager / node --env-file) +SERVER_PRIVATE_KEY= +# client/.env (Vite) — only needed if the API isn't at http://localhost:3000 +VITE_API_URL=https://api.example.com +``` + +Because the dev client (`:5173`) and server (`:3000`) are different origins, the server enables **CORS** for `CLIENT_ORIGIN` so the demos work on `npm run dev` with no extra setup. + +#### Home demo hub + +In a new glued project, the generated `Home` page shows the connect flow; once a wallet connects it lists every installed capability's demo page (e.g. *Wallet login*, *Signed request demo*), and each demo page has a “← Back to home” link. + +## Examples + +Scaffold a new React app with wallet login, non-interactively: + +```bash +npx create-bsv-app --dir my-app --mode new --name my-app \ + --frontend react --capabilities wallet-login --yes +``` + +Add wallet login to the project in the current directory (mode inferred from the existing manifest): + +```bash +npx create-bsv-app --capabilities wallet-login --yes +``` + +## Using `--file` + +Write the full config as JSON and pass it with `--file`. Example — a new monorepo (React client + Express server) with wallet login: + +`config.json` + +```json +{ + "mode": "new", + "name": "my-app", + "stack": { + "frontend": { "framework": "react", "variant": "react-ts" }, + "backend": { "framework": "express" } + }, + "bsvDir": "src/bsv", + "capabilities": ["wallet-login"], + "glue": false, + "packageManager": "pnpm", + "network": "test" +} +``` + +```bash +npx create-bsv-app --dir my-app --file config.json +``` + +Unspecified fields fall back to defaults (`dir`→`.`, `bsvDir`→`src/bsv`, `glue`→`false`, `packageManager`→`npm`, `network`→`test`). A `new` config must declare at least a frontend or a backend. + +**Mode with `--file`.** The file is the source of truth, so its `"mode"` field decides new vs. add — set `"mode": "add"` to add capabilities to an existing project via a config file. A `--mode` flag passed alongside `--file` **overrides** the file's mode (resolved through the same validation, so the new-mode baseline still applies). Handy with a saved manifest: + +```bash +npx create-bsv-app --dir my-app --file bsv-scaffold.json # reproduce: mode defaults to new +npx create-bsv-app --dir my-app --file bsv-scaffold.json --mode add # re-apply the recorded capabilities (add) +``` + +(For a quick interactive add you don't need `--file` at all — just re-run `npx create-bsv-app` inside a project that already has a `bsv-scaffold.json`; see *Re-running* below.) + +## Using `--ui` + +```bash +npx create-bsv-app --ui --dir my-app +``` + +Starts a local server on `127.0.0.1`, opens your browser, and renders the config as accordions. New vs. add mode and the offered capabilities follow the target directory's existing `bsv-scaffold.json` exactly as the CLI prompt does. Press **Generate** to scaffold; the page also displays the equivalent `npx create-bsv-app …` command for scripting/reproducibility. + +### Resulting manifest (`bsv-scaffold.json`) + +Every run writes a `bsv-scaffold.json` to the target directory. It records what was installed so later `add` runs can re-use the stack and skip already-installed capabilities: + +```json +{ + "version": 1, + "name": "my-app", + "network": "test", + "stack": { + "frontend": { "framework": "react", "variant": "react-ts" }, + "backend": { "framework": "express" } + }, + "bsvDir": "src/bsv", + "capabilities": ["wallet-login"] +} +``` + +## Re-running (add mode) + +Run the tool again in a directory that already has a `bsv-scaffold.json` and it switches to `add` mode automatically: it re-uses the recorded stack, offers only capabilities you don't already have, places their files, and merges them into the manifest. Existing utility files are left untouched unless you pass `--force`. + +## For AI agents + +The fastest path is the `--file` door: translate the user's requirements into a `ProjectConfig` JSON (the shape shown under *Using `--file`* above) and run `npx create-bsv-app --file config.json --dir `. This bypasses all interactive prompts and is fully deterministic. After scaffolding, read the generated `AGENTS.md` — it documents each installed capability's API and how to wire it into the app. + +## Project structure + +Everything funnels through **one flow**: a *door* produces a single `ProjectConfig`, which `applyConfig` dispatches by mode. + +``` +door (CLI flags · interactive · --file · --ui) + │ produces one ProjectConfig + ▼ +applyConfig (pipeline.ts) + ├── mode 'new' → scaffoldNewProject: base generator(s) → assemble base files → capability files → manifest → deps + └── mode 'add' → addCapabilities: capability files → manifest → deps +``` + +| Path | Responsibility | +| --- | --- | +| `src/index.ts` | Bin entry — runs `run()`, prints the result, formats errors. | +| `src/cli.ts` | `run()` + `parseArgs`: gather a `ProjectConfig` from a door, then call `applyConfig`. | +| `src/pipeline.ts` | `applyConfig` — the mode dispatch (`scaffoldNewProject` / `addCapabilities`) + `RunResult`. | +| `src/config/` | The config layer. `model.ts` (types), `schema.ts` (the **one** field schema that drives both the CLI prompt and the `--ui` page), `validate.ts` (`resolveConfig`), `draft.ts` (`seedDraft`/`resolveDraft`), `file.ts` (`--file`), `project-manifest.ts` (`bsv-scaffold.json`). | +| `src/registry.ts` | The capability list + lookup + `requires` expansion. | +| `src/capabilities/` | **One file per capability** (`wallet-connect`, `wallet-login`, `signed-requests`). This is where you add new ones. | +| `src/engine.ts` | `planPlacement` (maps each capability file's role → target dir, collects deps) + `writeFiles`. | +| `src/scaffold/` | New-project scaffolding: `new-project.ts` (orchestrator), `base-app.ts` (slot-templated `main.tsx`/`App.tsx`/`Home.tsx`/server entry + the `BaseBuilder`/`bsvImport` helpers), `vite.ts` / `express-skeleton.ts` / `base-scaffolder.ts` (base generators), `package-json.ts`, `run-command.ts`. | +| `src/agents-md.ts` | Renders the generated `AGENTS.md` (per-capability docs + wiring snippets). | +| `src/ui/` | The `--ui` door: `ui-server.ts` (ephemeral server), `ui-page.ts` (self-contained HTML), `open-browser.ts`. | +| `src/prompts.ts` | The interactive CLI prompts, driven by `config/schema.ts`. | + +## Adding a capability + +A capability is one file exporting a `Capability` (see the interface in `src/types.ts`). To contribute one via PR: + +1. **Create `src/capabilities/.ts`** exporting a `Capability`: + - `id`, `title`, `description` — identity and the one-line picker copy. + - `roles: ('shared' | 'client' | 'server')[]` — where its files belong. `planPlacement` maps roles to targets (in a monorepo: `shared` → both, `client` → `client/`, `server` → `server/`). + - `requires?` — ids of capabilities that must come too (e.g. `['wallet-connect']`). + - `defaultSelected?` — pre-selected for new projects (only `wallet-connect` sets this today). + - `files(ctx)` — the `FileSpec[]` per role, written under `ctx.bsvDir`. Use `bsvImport(ctx, name)` (from `scaffold/base-app.js`) for any import path between generated files so non-default `--bsv-dir` still resolves. + - `baseEdits?({ builder, ctx })` — *new-project glue only*. Contribute to the assembled base files: `builder.main.{imports,wraps}`, `builder.app.routes` (route descriptors `{ path, component, importPath, label }` — the scaffolder generates the import + `` and the Home-hub link), `builder.server.{imports,routes}`. + - `npmDependencies(ctx)` — deps per role, merged into the right `package.json`. + - `agentsSection(ctx)` — the `AGENTS.md` entry. Follow the existing shape: **How it works / How it's used / Future integrations**. +2. **Register it** in `src/registry.ts`. +3. **Add tests** in `src/capabilities/__tests__/.test.ts` — assert `files`/`roles`/`requires`, the `baseEdits` descriptors, and key content of the generated files. +4. Run `npx jest && npm run lint:ci && npm run build`. The registry-consistency tests will check your capability conforms. + +Keep capabilities focused: scaffold the BSV-specific mechanism as working code, and *document* the standard/opinionated extensions (sessions, stores, deployment) in `agentsSection` rather than scaffolding them. diff --git a/packages/helpers/create-bsv-app/eslint.config.js b/packages/helpers/create-bsv-app/eslint.config.js new file mode 100644 index 000000000..668203905 --- /dev/null +++ b/packages/helpers/create-bsv-app/eslint.config.js @@ -0,0 +1,2 @@ +import tsStandard from 'ts-standard' +export default [tsStandard] diff --git a/packages/helpers/create-bsv-app/jest.config.cjs b/packages/helpers/create-bsv-app/jest.config.cjs new file mode 100644 index 000000000..22d7e1637 --- /dev/null +++ b/packages/helpers/create-bsv-app/jest.config.cjs @@ -0,0 +1,11 @@ +module.exports = { + preset: 'ts-jest', + testEnvironment: 'node', + roots: ['/src'], + testMatch: ['**/__tests__/**/*.test.ts'], + moduleFileExtensions: ['ts', 'js', 'json', 'node'], + moduleNameMapper: { '^(\\.{1,2}/.*)\\.js$': '$1' }, + transform: { + '^.+\\.tsx?$': ['ts-jest', { tsconfig: { module: 'commonjs', moduleResolution: 'bundler', esModuleInterop: true, types: ['node', 'jest'] } }] + } +} diff --git a/packages/helpers/create-bsv-app/package.json b/packages/helpers/create-bsv-app/package.json new file mode 100644 index 000000000..c07d9329a --- /dev/null +++ b/packages/helpers/create-bsv-app/package.json @@ -0,0 +1,34 @@ +{ + "name": "create-bsv-app", + "version": "0.1.0", + "description": "CLI that installs BSV capabilities (util files built on the abstraction libs) into a project", + "type": "module", + "main": "dist/index.js", + "bin": { "create-bsv-app": "./dist/index.js" }, + "files": ["dist"], + "scripts": { + "build": "tsc", + "test": "jest", + "lint": "ts-standard --fix src/**/*.ts", + "lint:ci": "ts-standard src/**/*.ts", + "clean": "rm -rf dist coverage", + "dev": "node dist/index.js" + }, + "dependencies": { "@clack/prompts": "^0.7.0" }, + "devDependencies": { + "@jest/globals": "^30.4.1", + "@types/jest": "^30.0.0", + "@types/node": "^26.0.0", + "jest": "^30.4.2", + "ts-jest": "^29.4.11", + "ts-standard": "^12.0.2", + "typescript": "^6.0.3" + }, + "publishConfig": { "access": "public" }, + "ts-standard": { + "project": "tsconfig.eslint.json", + "ignore": [ + "dist" + ] + } +} diff --git a/packages/helpers/create-bsv-app/src/__tests__/agents-md.test.ts b/packages/helpers/create-bsv-app/src/__tests__/agents-md.test.ts new file mode 100644 index 000000000..f9a1b3529 --- /dev/null +++ b/packages/helpers/create-bsv-app/src/__tests__/agents-md.test.ts @@ -0,0 +1,82 @@ +// src/__tests__/agents-md.test.ts +import { describe, expect, test } from '@jest/globals' +import { renderAgentsMd } from '../agents-md' +import { walletConnect } from '../capabilities/wallet-connect' +import { walletLogin } from '../capabilities/wallet-login' +import type { ProjectConfig } from '../config/model' + +const config: ProjectConfig = { + mode: 'add', + name: 'demo', + dir: '.', + stack: { frontend: { framework: 'react', variant: 'react-ts' } }, + bsvDir: 'src/bsv', + capabilities: ['wallet-connect', 'wallet-login'], + glue: false, + packageManager: 'npm', + network: 'test' +} + +describe('renderAgentsMd', () => { + test('includes header, deps, and the wallet-login section', () => { + // wallet-login requires wallet-connect; render both so @bsv/auth (from wallet-connect) appears in deps + const md = renderAgentsMd(config, [walletConnect, walletLogin]) + expect(md).toContain('# demo — agent guide') + expect(md).toContain('## Install dependencies') + expect(md).toContain('@bsv/auth') + expect(md).toContain('## wallet-login') + expect(md).toContain('## wallet-connect') + }) + + test('throws on file conflict (two caps same path different content)', () => { + const capX = { + id: 'x', + title: 'X', + description: 'x', + roles: ['shared' as const], + files: () => ({ shared: [{ path: 'clash.ts', content: 'from-x' }] }), + npmDependencies: () => ({}), + agentsSection: () => '' + } + const capY = { + id: 'y', + title: 'Y', + description: 'y', + roles: ['shared' as const], + files: () => ({ shared: [{ path: 'clash.ts', content: 'from-y' }] }), + npmDependencies: () => ({}), + agentsSection: () => '' + } + expect(() => renderAgentsMd(config, [capX, capY])).toThrow(/file conflict/i) + }) + + test('add-mode: wiring section shows route JSX and server route snippet to paste', () => { + const md = renderAgentsMd(config, [walletConnect, walletLogin]) + // Should include a Wiring (manual) heading + expect(md).toContain('Wiring') + // App.tsx route import generated from the route descriptor + expect(md).toContain("import { WalletLogin } from './bsv/WalletLogin'") + // Route JSX generated from the route descriptor + expect(md).toContain('} />') + // Server route snippet + expect(md).toContain("app.post('/api/login', loginRoute(serverWallet))") + // SERVER_PRIVATE_KEY note because there is a server route + expect(md).toContain('SERVER_PRIVATE_KEY') + expect(md).toContain('.env') + }) + + test('new+glue: wiring section says wired automatically, no snippet dump', () => { + const newGlueConfig: ProjectConfig = { + ...config, + mode: 'new', + glue: true + } + const md = renderAgentsMd(newGlueConfig, [walletConnect, walletLogin]) + // Should say wired automatically + expect(md).toContain('wired automatically') + // Should NOT contain the route JSX snippet in a fenced code block + expect(md).not.toContain('} />') + // Should NOT dump the server/src/index.ts wiring block + expect(md).not.toContain('### `server/src/index.ts`') + }) +}) diff --git a/packages/helpers/create-bsv-app/src/__tests__/cli.test.ts b/packages/helpers/create-bsv-app/src/__tests__/cli.test.ts new file mode 100644 index 000000000..8befd50a6 --- /dev/null +++ b/packages/helpers/create-bsv-app/src/__tests__/cli.test.ts @@ -0,0 +1,333 @@ +import { describe, expect, test, beforeEach, afterEach } from '@jest/globals' +import { mkdtempSync, mkdirSync, rmSync, existsSync, readFileSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { parseArgs, run } from '../cli' +import type { RunCommand } from '../scaffold/base-scaffolder' +import type { RunResult } from '../pipeline' + +let dir: string +beforeEach(() => { dir = mkdtempSync(join(tmpdir(), 'cba-cli-')) }) +afterEach(() => { rmSync(dir, { recursive: true, force: true }) }) + +describe('parseArgs', () => { + test('collects config flags into draft + control flags', () => { + const a = parseArgs(['--dir', 'x', '--mode', 'new', '--frontend', 'react', '--capabilities', 'wallet-login', '--yes']) + expect(a).toMatchObject({ dir: 'x', yes: true, force: false }) + expect(a.draft).toMatchObject({ mode: 'new', frontend: 'react', capabilities: ['wallet-login'] }) + }) + + test('defaults: yes=false, force=false, draft={}', () => { + const a = parseArgs([]) + expect(a.yes).toBe(false) + expect(a.force).toBe(false) + expect(a.draft).toEqual({}) + }) + + test('--force sets force:true', () => { + const a = parseArgs(['--force']) + expect(a.force).toBe(true) + }) + + test('--file captures file path', () => { + const a = parseArgs(['--file', '/some/config.json']) + expect(a.file).toBe('/some/config.json') + }) + + test('--mode add sets draft.mode=add', () => { + const a = parseArgs(['--mode', 'add']) + expect(a.draft.mode).toBe('add') + }) + + test('trailing --dir flag with no value does not blow up', () => { + expect(() => parseArgs(['--dir'])).not.toThrow() + }) + + test('--capabilities splits comma-separated values into array', () => { + const a = parseArgs(['--capabilities', 'wallet-login,another-cap']) + expect(a.draft.capabilities).toEqual(['wallet-login', 'another-cap']) + }) + + test('--network main sets draft.network=main', () => { + const a = parseArgs(['--network', 'main']) + expect(a.draft.network).toBe('main') + }) + + test('positional arg sets dir', () => { + const a = parseArgs(['myproject']) + expect(a.dir).toBe('myproject') + }) + + test('--ui sets ui:true', () => { + expect(parseArgs(['--ui']).ui).toBe(true) + }) + test('ui defaults to false', () => { + expect(parseArgs([]).ui).toBe(false) + }) + test('--no-glue sets draft.glue=false', () => { + expect(parseArgs(['--no-glue']).draft.glue).toBe(false) + }) +}) + +describe('run --yes new (flags)', () => { + test('scaffolds new react project with wallet-connect via fake runCommand', async () => { + const calls: string[][] = [] + const fake: RunCommand = (command, args) => { calls.push([command, ...args]) } + const res = await run( + ['--dir', dir, '--mode', 'new', '--name', 'demo', '--frontend', 'react', '--capabilities', 'wallet-connect', '--yes'], + undefined, + { runCommand: fake } + ) + expect(calls.some(c => c.includes('vite@latest'))).toBe(true) + expect(res.written).toContain('src/bsv/auth.ts') + expect(res.written).toContain('src/bsv/walletAcquisition.ts') + expect(res.deps.root).toHaveProperty('@bsv/sdk') + expect(existsSync(join(dir, 'AGENTS.md'))).toBe(true) + const manifest = JSON.parse(readFileSync(join(dir, 'bsv-scaffold.json'), 'utf8')) + expect(manifest.version).toBe(1) + expect(manifest.stack.frontend.framework).toBe('react') + expect(manifest.capabilities).toContain('wallet-connect') + }) + + // Item 1: new-mode with --capabilities wallet-login expands requires → wallet-connect + wallet-login + test('new-mode --capabilities wallet-login pre-seeds wallet-connect (requires expansion)', async () => { + const calls: string[][] = [] + const fake: RunCommand = (command, args) => { calls.push([command, ...args]) } + const res = await run( + ['--dir', dir, '--mode', 'new', '--name', 'demo', '--frontend', 'react', '--capabilities', 'wallet-login', '--yes'], + undefined, + { runCommand: fake } + ) + expect(calls.some(c => c.includes('vite@latest'))).toBe(true) + // auth.ts comes from wallet-connect (expanded from wallet-login requires) + expect(res.written).toContain('src/bsv/auth.ts') + // wallet-login client file + expect(res.written).toContain('src/bsv/useWalletLogin.tsx') + const manifest = JSON.parse(readFileSync(join(dir, 'bsv-scaffold.json'), 'utf8')) + // seedDraft pre-selects defaultSelected caps (wallet-connect), so both end up in manifest + expect(manifest.capabilities).toEqual(['wallet-connect', 'wallet-login']) + }) +}) + +describe('run --yes new --no-glue with a variant', () => { + test('emits the contexts (so the hook compiles) but skips the main.tsx wiring', async () => { + const fake: RunCommand = () => {} + const res = await run( + ['--dir', dir, '--mode', 'new', '--name', 'demo', '--frontend', 'react', '--capabilities', 'wallet-login', '--no-glue', '--yes'], + undefined, + { runCommand: fake } + ) + // contexts are core files now → present even with --no-glue, so useWalletLogin's import resolves + expect(res.written).toContain('src/bsv/WalletContext.tsx') + expect(res.written).toContain('src/bsv/useWalletLogin.tsx') + expect(res.written).toContain('src/bsv/auth.ts') + // main.tsx assembly is suppressed by --no-glue; vite is faked so it isn't created at all + expect(existsSync(join(dir, 'src/main.tsx'))).toBe(false) + }) +}) + +describe('run --yes add (existing manifest)', () => { + test('adds wallet-connect to existing express project (no runCommand called)', async () => { + // First: scaffold new express project with wallet-connect + const newCalls: string[][] = [] + const fake: RunCommand = () => { newCalls.push([]) } + await run( + ['--dir', dir, '--mode', 'new', '--name', 'myapp', '--backend', 'express', '--capabilities', 'wallet-connect', '--yes'], + undefined, + { runCommand: fake } + ) + const firstManifest = JSON.parse(readFileSync(join(dir, 'bsv-scaffold.json'), 'utf8')) + expect(firstManifest.stack.backend.framework).toBe('express') + + // Second: add-mode run — existing manifest detected, no runCommand needed + const addCalls: string[][] = [] + const fakeAdd: RunCommand = () => { addCalls.push([]); throw new Error('runCommand should not be called in add mode') } + await run( + ['--dir', dir, '--capabilities', 'wallet-connect', '--yes'], + undefined, + { runCommand: fakeAdd } + ) + expect(addCalls).toHaveLength(0) + // written may be 0 (files already exist, skipped) but AGENTS.md always written + expect(existsSync(join(dir, 'AGENTS.md'))).toBe(true) + expect(existsSync(join(dir, 'bsv-scaffold.json'))).toBe(true) + }) + + // Item 2: add-mode with wallet-login — expandRequires:false — must NOT pull wallet-connect + test('add-mode wallet-login installs exactly wallet-login files; does NOT auto-pull wallet-connect', async () => { + // Use a subdirectory within dir so beforeEach/afterEach handles cleanup + const addDir = join(dir, 'add-only') + const fake: RunCommand = () => {} + // Use --file add-mode with only wallet-login (no wallet-connect in the config) + const cfgPath = join(dir, 'config.json') + writeFileSync(cfgPath, JSON.stringify({ + mode: 'add', + name: 'addtest', + stack: { frontend: { framework: 'react', variant: 'react-ts' } }, + capabilities: ['wallet-login'] + }), 'utf8') + const res = await run(['--dir', addDir, '--file', cfgPath], undefined, { runCommand: fake }) + // wallet-login is placed (its own client file) + expect(res.written).toContain('src/bsv/useWalletLogin.tsx') + const manifest = JSON.parse(readFileSync(join(addDir, 'bsv-scaffold.json'), 'utf8')) + // ONLY wallet-login in manifest (add-mode does not expand requires) + expect(manifest.capabilities).toEqual(['wallet-login']) + // wallet-connect's files must NOT be placed (expandRequires:false in add-mode) + expect(res.written).not.toContain('src/bsv/walletAcquisition.ts') + expect(res.written).not.toContain('src/bsv/WalletContext.tsx') + }) +}) + +describe('run --file (direct manifest door)', () => { + test('new-mode config file scaffolds via fake runCommand', async () => { + const calls: string[][] = [] + const fake: RunCommand = (command, args) => { calls.push([command, ...args]) } + const cfgPath = join(dir, 'config.json') + const target = join(dir, 'app') // keep target empty (config.json lives in parent) + writeFileSync(cfgPath, JSON.stringify({ + mode: 'new', + name: 'from-file', + stack: { frontend: { framework: 'react', variant: 'react-ts' } }, + capabilities: ['wallet-login'] + }), 'utf8') + const res = await run(['--dir', target, '--file', cfgPath], undefined, { runCommand: fake }) + expect(calls.some(c => c.includes('vite@latest'))).toBe(true) + // new-mode expands requires: wallet-login → wallet-connect + wallet-login, auth.ts from wallet-connect + expect(res.written).toContain('src/bsv/auth.ts') + const manifest = JSON.parse(readFileSync(join(target, 'bsv-scaffold.json'), 'utf8')) + expect(manifest.capabilities).toEqual(['wallet-connect', 'wallet-login']) + }) + + test('new-mode config with zero capabilities still scaffolds the wallet-connect baseline', async () => { + const fake: RunCommand = () => {} + const cfgPath = join(dir, 'cfg.json') + const target = join(dir, 'app0') + writeFileSync(cfgPath, JSON.stringify({ + mode: 'new', + name: 'zero', + stack: { frontend: { framework: 'react', variant: 'react-ts' } }, + capabilities: [] + }), 'utf8') + const res = await run(['--dir', target, '--file', cfgPath], undefined, { runCommand: fake }) + expect(res.written).toContain('src/bsv/auth.ts') + expect(res.written).toContain('src/bsv/WalletContext.tsx') + const manifest = JSON.parse(readFileSync(join(target, 'bsv-scaffold.json'), 'utf8')) + expect(manifest.capabilities).toEqual(['wallet-connect']) + }) + + test('new-mode scaffolds normally when the dir holds only a manifest (reproduce-from-manifest)', async () => { + // Drop just a bsv-scaffold.json into the target and scaffold a NEW project from it. + const target = join(dir, 'reproduce') + mkdirSync(target, { recursive: true }) + const manifestPath = join(target, 'bsv-scaffold.json') + writeFileSync(manifestPath, JSON.stringify({ + version: 1, + name: 'reproduced', + network: 'test', + stack: { frontend: { framework: 'react', variant: 'react-ts' } }, + bsvDir: 'src/bsv', + capabilities: ['wallet-connect', 'wallet-login'] + }), 'utf8') + const calls: string[][] = [] + const fake: RunCommand = (command, args) => { calls.push([command, ...args]) } + // A lone manifest must NOT trip the empty-dir guard; new mode runs the base generator. + const res = await run(['--dir', target, '--file', manifestPath], undefined, { runCommand: fake }) + expect(calls.some(c => c.includes('vite@latest'))).toBe(true) + expect(res.written).toContain('src/bsv/auth.ts') + // the existing manifest is rewritten (regenerated from the config) + const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')) + expect(manifest.capabilities).toEqual(['wallet-connect', 'wallet-login']) + }) + + test('new-mode still errors when the dir holds non-manifest files', async () => { + const target = join(dir, 'dirty') + mkdirSync(target, { recursive: true }) + writeFileSync(join(target, 'README.md'), '# pre-existing', 'utf8') + const cfgPath = join(dir, 'c.json') + writeFileSync(cfgPath, JSON.stringify({ mode: 'new', name: 'x', stack: { frontend: { framework: 'react', variant: 'react-ts' } } }), 'utf8') + await expect(run(['--dir', target, '--file', cfgPath], undefined, { runCommand: () => {} })).rejects.toThrow(/not empty/i) + }) + + test('new-mode scaffolds into a freshly git-init-ed dir (a lone .git does not count as non-empty)', async () => { + const target = join(dir, 'gitfirst') + mkdirSync(join(target, '.git'), { recursive: true }) // simulate `git init` + writeFileSync(join(target, '.git', 'HEAD'), 'ref: refs/heads/main\n', 'utf8') + const cfgPath = join(dir, 'g.json') + writeFileSync(cfgPath, JSON.stringify({ mode: 'new', name: 'g', stack: { backend: { framework: 'express' } } }), 'utf8') + const res = await run(['--dir', target, '--file', cfgPath], undefined, { runCommand: () => {} }) + expect(res.written).toContain('bsv-scaffold.json') // scaffolded, no "not empty" error + expect(existsSync(join(target, '.git', 'HEAD'))).toBe(true) // .git left untouched + }) + + test('--mode add overrides a file whose mode is new (runs add, no base generator)', async () => { + const fakeAdd: RunCommand = () => { throw new Error('runCommand should not run in add mode') } + const cfgPath = join(dir, 'newish.json') + // file declares mode:new + a frontend, but --mode add must override → add path + writeFileSync(cfgPath, JSON.stringify({ + mode: 'new', name: 'ov', stack: { frontend: { framework: 'react', variant: 'react-ts' } }, capabilities: ['wallet-login'] + }), 'utf8') + const res = await run(['--dir', dir, '--file', cfgPath, '--mode', 'add'], undefined, { runCommand: fakeAdd }) + // add mode: only wallet-login's own files, no wallet-connect floor pulled in + expect(res.written).toContain('src/bsv/useWalletLogin.tsx') + expect(res.written).not.toContain('src/bsv/auth.ts') + const manifest = JSON.parse(readFileSync(join(dir, 'bsv-scaffold.json'), 'utf8')) + expect(manifest.capabilities).toEqual(['wallet-login']) + }) + + test('add-mode config file places wallet-login files only (no auth.ts, expandRequires:false)', async () => { + const fakeAdd: RunCommand = () => { throw new Error('runCommand should not be called in add mode') } + const cfgPath = join(dir, 'config.json') + writeFileSync(cfgPath, JSON.stringify({ + mode: 'add', + name: 'add-from-file', + stack: { frontend: { framework: 'react', variant: 'react-ts' } }, + capabilities: ['wallet-login'] + }), 'utf8') + const res = await run(['--dir', dir, '--file', cfgPath], undefined, { runCommand: fakeAdd }) + // wallet-login in add-mode: no auth.ts (that's wallet-connect's file) + expect(res.written).not.toContain('src/bsv/auth.ts') + // wallet-login's own client file is placed + expect(res.written).toContain('src/bsv/useWalletLogin.tsx') + expect(existsSync(join(dir, 'AGENTS.md'))).toBe(true) + expect(existsSync(join(dir, 'bsv-scaffold.json'))).toBe(true) + }) +}) + +describe('run interactive (no --yes)', () => { + test('throws if no provider given', async () => { + await expect(run(['--dir', dir])).rejects.toThrow(/interactive run requires a config provider/) + }) + + test('calls provider with existing=null for fresh dir, uses returned config', async () => { + const calls: string[][] = [] + const fake: RunCommand = (command, args) => { calls.push([command, ...args]) } + const provider = async (): Promise => ({ + mode: 'new', + name: 'interactive-test', + dir: '.', + stack: { frontend: { framework: 'react', variant: 'react-ts' } }, + bsvDir: 'src/bsv', + capabilities: ['wallet-login'], + glue: false, + packageManager: 'npm', + network: 'test' + }) + const res = await run(['--dir', dir], provider, { runCommand: fake }) + expect(calls.some(c => c.includes('vite@latest'))).toBe(true) + // new-mode expands requires: wallet-login → wallet-connect + wallet-login, so auth.ts is placed + expect(res.written).toContain('src/bsv/auth.ts') + }) +}) + +describe('run --ui', () => { + test('delegates to the injected startUi with existing + targetDir and returns its result', async () => { + const seen: Array<{ targetDir: string }> = [] + const stub = async (o: { existing: unknown, targetDir: string, runCommand?: unknown }): Promise => { + seen.push({ targetDir: o.targetDir }) + return { targetDir: o.targetDir, deps: { root: {}, client: {}, server: {} }, written: ['src/bsv/auth.ts'], skipped: [] } + } + const res = await run(['--dir', dir, '--ui'], undefined, { startUi: stub }) + expect(seen).toEqual([{ targetDir: dir }]) + expect(res.written).toContain('src/bsv/auth.ts') + }) +}) diff --git a/packages/helpers/create-bsv-app/src/__tests__/config-prompt-add.test.ts b/packages/helpers/create-bsv-app/src/__tests__/config-prompt-add.test.ts new file mode 100644 index 000000000..bb29d0ef3 --- /dev/null +++ b/packages/helpers/create-bsv-app/src/__tests__/config-prompt-add.test.ts @@ -0,0 +1,60 @@ +// src/__tests__/config-prompt-add.test.ts +// Isolated test for add-mode capabilities ask + union. +// Uses a two-capability mock registry so a new cap can be added on top of an existing one. +import { describe, expect, test, jest } from '@jest/globals' +import type { Capability } from '../types' +import { runPrompts } from '../prompts' +import type { Ask } from '../prompts' +import type { ConfigField } from '../config/schema' +import type { ProjectManifest } from '../config/project-manifest' + +// Capability definitions live inside the factory so they are accessible when jest hoists the call +jest.mock('../registry', () => { + const a: Capability = { + id: 'a', + title: 'Cap A', + description: 'First fake cap', + roles: ['shared'], + files: () => ({ shared: [{ path: 'a.ts', content: 'a' }] }), + npmDependencies: () => ({}), + agentsSection: () => '' + } + const b: Capability = { + id: 'b', + title: 'Cap B', + description: 'Second fake cap', + roles: ['shared'], + files: () => ({ shared: [{ path: 'b.ts', content: 'b' }] }), + npmDependencies: () => ({}), + agentsSection: () => '' + } + return { + getCapability: (id: string): Capability | undefined => [a, b].find(c => c.id === id), + listCapabilities: (): Capability[] => [a, b] + } +}) + +describe('runPrompts – add mode (mocked 2-cap registry)', () => { + test('add mode: capabilities IS asked and the result is unioned with existing', async () => { + const existing: ProjectManifest = { + version: 1, + name: 'demo', + network: 'test', + stack: { frontend: { framework: 'react', variant: 'react-ts' } }, + bsvDir: 'src/bsv', + capabilities: ['a'] + } + const askedKeys: string[] = [] + const ask: Ask = async (field: ConfigField) => { + askedKeys.push(field.key) + // Answer 'b' when asked about capabilities; undefined for any other field + return field.key === 'capabilities' ? ['b'] : undefined + } + const c = await runPrompts({ existing, flags: {} }, ask) + expect(askedKeys).toContain('capabilities') // must be asked in add mode + expect(askedKeys).not.toContain('frontend') // locked / hidden in add mode + expect(c.mode).toBe('add') + expect(c.stack.frontend?.framework).toBe('react') + expect(c.capabilities).toEqual(['a', 'b']) // union: existing 'a' + newly added 'b' + }) +}) diff --git a/packages/helpers/create-bsv-app/src/__tests__/config-prompt.test.ts b/packages/helpers/create-bsv-app/src/__tests__/config-prompt.test.ts new file mode 100644 index 000000000..206d2d791 --- /dev/null +++ b/packages/helpers/create-bsv-app/src/__tests__/config-prompt.test.ts @@ -0,0 +1,47 @@ +// src/__tests__/config-prompt.test.ts +import { describe, expect, test } from '@jest/globals' +import { runPrompts } from '../prompts' +import type { Ask } from '../prompts' +import type { FieldOption } from '../config/schema' +import type { ProjectManifest } from '../config/project-manifest' + +describe('runPrompts', () => { + test('new mode: asks the full set, builds a ProjectConfig', async () => { + const capabilityOptions: FieldOption[] = [] + const ask: Ask = async (field, options) => { + if (field.key === 'capabilities') capabilityOptions.push(...options) + const scripted: Record = { mode: 'new', name: 'demo', frontend: 'react', frontendVariant: 'react-ts', backend: 'none', bsvDir: 'src/bsv', capabilities: ['wallet-login'], glue: false, packageManager: 'npm', network: 'test' } + return scripted[field.key] + } + const c = await runPrompts({ existing: null, flags: {} }, ask) + expect(c.mode).toBe('new') + expect(c.stack.frontend?.framework).toBe('react') + // wallet-connect is defaultSelected → excluded from new-mode picker options + expect(capabilityOptions.map(o => o.value)).not.toContain('wallet-connect') + // wallet-login IS offered as a picker option in new mode + expect(capabilityOptions.map(o => o.value)).toContain('wallet-login') + // wallet-connect is still in the final config (pre-seeded by seedDraft + floored by resolveConfig) + expect(c.capabilities).toEqual(expect.arrayContaining(['wallet-connect', 'wallet-login'])) + expect(c.capabilities).toHaveLength(2) + }) + + test('add mode: locks fields from the manifest, only asks capabilities, unions', async () => { + const existing: ProjectManifest = { version: 1, name: 'demo', network: 'test', stack: { frontend: { framework: 'react', variant: 'react-ts' } }, bsvDir: 'src/bsv', capabilities: ['wallet-login'] } + const askedKeys: string[] = [] + const ask: Ask = async (field) => { askedKeys.push(field.key); return field.key === 'capabilities' ? [] : undefined } + const c = await runPrompts({ existing, flags: {} }, ask) + expect(askedKeys).not.toContain('frontend') // locked / hidden in add mode + expect(c.mode).toBe('add') + expect(c.stack.frontend?.framework).toBe('react') + expect(c.capabilities).toEqual(['wallet-login']) + }) + + test('flags prefill skips asking that field', async () => { + const askedKeys: string[] = [] + const ask: Ask = async (field) => { askedKeys.push(field.key); return field.key === 'name' ? 'fromPrompt' : field.key === 'frontend' ? 'react' : field.key === 'capabilities' ? ['wallet-login'] : undefined } + const c = await runPrompts({ existing: null, flags: { mode: 'new', name: 'fromFlag' } }, ask) + expect(askedKeys).not.toContain('mode') // set by flag + expect(askedKeys).not.toContain('name') // set by flag + expect(c.name).toBe('fromFlag') + }) +}) diff --git a/packages/helpers/create-bsv-app/src/__tests__/pipeline.test.ts b/packages/helpers/create-bsv-app/src/__tests__/pipeline.test.ts new file mode 100644 index 000000000..63e7999bd --- /dev/null +++ b/packages/helpers/create-bsv-app/src/__tests__/pipeline.test.ts @@ -0,0 +1,78 @@ +import { expect, test, beforeEach, afterEach } from '@jest/globals' +import { mkdtempSync, rmSync, existsSync, readFileSync, mkdirSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { applyConfig } from '../pipeline' +import type { ProjectConfig } from '../config/model' +import type { RunCommand } from '../scaffold/base-scaffolder' + +let dir: string +beforeEach(() => { dir = mkdtempSync(join(tmpdir(), 'cba-pipe-')) }) +afterEach(() => { rmSync(dir, { recursive: true, force: true }) }) + +const newConfig: ProjectConfig = { + mode: 'new', + name: 'demo', + dir: '.', + stack: { frontend: { framework: 'react', variant: 'react-ts' } }, + bsvDir: 'src/bsv', + capabilities: ['wallet-login'], + glue: false, + packageManager: 'npm', + network: 'test' +} + +test('applyConfig new-mode scaffolds via runCommand and reports skipped=[]', () => { + const calls: string[][] = [] + const fake: RunCommand = (command, args) => { calls.push([command, ...args]) } + const res = applyConfig(newConfig, dir, { runCommand: fake }) + expect(calls.some(c => c.includes('vite@latest'))).toBe(true) + // new-mode expands requires: wallet-login → wallet-connect + wallet-login, so auth.ts comes from wallet-connect + expect(res.written).toContain('src/bsv/auth.ts') + expect(res.skipped).toEqual([]) + expect(existsSync(join(dir, 'bsv-scaffold.json'))).toBe(true) +}) + +test('applyConfig add-mode places only wallet-login files (no auth.ts, expandRequires:false)', () => { + // add-mode does NOT expand requires, so only wallet-login's own files are placed + const addConfig: ProjectConfig = { ...newConfig, mode: 'add' } + const boom: RunCommand = () => { throw new Error('must not run a command in add mode') } + const res = applyConfig(addConfig, dir, { runCommand: boom, force: false }) + // wallet-login has no shared/auth.ts — that lives in wallet-connect + expect(res.written).not.toContain('src/bsv/auth.ts') + // wallet-login client file is placed + expect(res.written).toContain('src/bsv/useWalletLogin.tsx') + const manifest = JSON.parse(readFileSync(join(dir, 'bsv-scaffold.json'), 'utf8')) + expect(manifest.capabilities).toEqual(['wallet-login']) +}) + +test('applyConfig add-mode with force:false preserves an existing util file', () => { + const addConfig: ProjectConfig = { ...newConfig, mode: 'add' } + const noop: RunCommand = () => {} + // pre-create the useWalletLogin.tsx with sentinel content + mkdirSync(join(dir, 'src', 'bsv'), { recursive: true }) + writeFileSync(join(dir, 'src', 'bsv', 'useWalletLogin.tsx'), '// SENTINEL', 'utf8') + const res = applyConfig(addConfig, dir, { runCommand: noop, force: false }) + expect(res.skipped).toContain('src/bsv/useWalletLogin.tsx') + expect(res.written).not.toContain('src/bsv/useWalletLogin.tsx') + expect(readFileSync(join(dir, 'src', 'bsv', 'useWalletLogin.tsx'), 'utf8')).toBe('// SENTINEL') +}) + +test('applyConfig new-mode written includes AGENTS.md, manifest, and main.tsx (matches /plan)', () => { + const calls: string[][] = [] + const fake: RunCommand = (command, args) => { calls.push([command, ...args]) } + const glueConfig: ProjectConfig = { ...newConfig, glue: true } + const res = applyConfig(glueConfig, dir, { runCommand: fake }) + expect(res.written).toContain('src/bsv/auth.ts') + expect(res.written).toContain('AGENTS.md') + expect(res.written).toContain('bsv-scaffold.json') + expect(res.written).toContain('src/main.tsx') // assembleAndWrite, new+glue+frontend-only +}) + +test('applyConfig add-mode written includes AGENTS.md and manifest', () => { + const addConfig: ProjectConfig = { ...newConfig, mode: 'add' } + const noop: RunCommand = () => {} + const res = applyConfig(addConfig, dir, { runCommand: noop, force: false }) + expect(res.written).toContain('AGENTS.md') + expect(res.written).toContain('bsv-scaffold.json') +}) diff --git a/packages/helpers/create-bsv-app/src/__tests__/placement.test.ts b/packages/helpers/create-bsv-app/src/__tests__/placement.test.ts new file mode 100644 index 000000000..08703020a --- /dev/null +++ b/packages/helpers/create-bsv-app/src/__tests__/placement.test.ts @@ -0,0 +1,145 @@ +// src/__tests__/placement.test.ts +import { describe, expect, test } from '@jest/globals' +import { planPlacement } from '../engine' +import { walletConnect } from '../capabilities/wallet-connect' +import { walletLogin } from '../capabilities/wallet-login' +import type { Capability, CapabilityContext } from '../types' +import type { ProjectConfig } from '../config/model' + +const monorepoConfig: ProjectConfig = { + mode: 'add', + name: 'demo', + dir: '.', + stack: { frontend: { framework: 'react', variant: 'react-ts' }, backend: { framework: 'express' } }, + bsvDir: 'src/bsv', + capabilities: ['wallet-connect', 'wallet-login'], + glue: false, + packageManager: 'npm', + network: 'test' +} + +const customBsvDirConfig: ProjectConfig = { + mode: 'add', + name: 'demo', + dir: '.', + stack: { frontend: { framework: 'react', variant: 'react-ts' }, backend: { framework: 'express' } }, + bsvDir: 'lib/bsv', + capabilities: ['wallet-connect', 'wallet-login'], + glue: false, + packageManager: 'npm', + network: 'test' +} + +// Fake capability with glue files +const capWithGlue: Capability = { + id: 'glue-cap', + title: 'Glue Cap', + description: 'fake cap with glue', + roles: ['shared'], + files: () => ({ shared: [{ path: 'util.ts', content: '// util' }] }), + glue: (_ctx: CapabilityContext) => ({ shared: [{ path: 'glue-entry.ts', content: '// glue' }] }), + npmDependencies: () => ({}), + agentsSection: () => '' +} + +// wallet-login requires wallet-connect; pass both (as resolveCapabilities would return in expand mode) +const bothCaps = [walletConnect, walletLogin] + +describe('planPlacement — monorepo', () => { + test('shared file (auth.ts) is duplicated into BOTH client and server bsvDir', () => { + const result = planPlacement(monorepoConfig, bothCaps) + const paths = result.utilFiles.map(f => f.path) + expect(paths).toContain('client/src/bsv/auth.ts') + expect(paths).toContain('server/src/bsv/auth.ts') + }) + + test('client-only file (useWalletLogin.tsx) is placed under client/ only', () => { + const result = planPlacement(monorepoConfig, bothCaps) + const paths = result.utilFiles.map(f => f.path) + expect(paths).toContain('client/src/bsv/useWalletLogin.tsx') + expect(paths).not.toContain('server/src/bsv/useWalletLogin.tsx') + }) + + test('server-only file (loginRoute.ts) is placed under server/ only', () => { + const result = planPlacement(monorepoConfig, bothCaps) + const paths = result.utilFiles.map(f => f.path) + expect(paths).toContain('server/src/bsv/loginRoute.ts') + expect(paths).not.toContain('client/src/bsv/loginRoute.ts') + }) + + test('no root-level bsv files in monorepo', () => { + const result = planPlacement(monorepoConfig, bothCaps) + const paths = result.utilFiles.map(f => f.path) + expect(paths).not.toContain('src/bsv/auth.ts') + expect(paths).not.toContain('src/bsv/useWalletLogin.tsx') + }) + + test('deps.client has @bsv/auth and @bsv/sdk (from wallet-connect shared role)', () => { + const result = planPlacement(monorepoConfig, bothCaps) + expect(result.deps.client).toHaveProperty('@bsv/auth') + expect(result.deps.client).toHaveProperty('@bsv/sdk') + }) + + test('deps.server has @bsv/auth, @bsv/sdk and express (wallet-connect shared + wallet-login server)', () => { + const result = planPlacement(monorepoConfig, bothCaps) + expect(result.deps.server).toHaveProperty('@bsv/auth') + expect(result.deps.server).toHaveProperty('@bsv/sdk') + expect(result.deps.server).toHaveProperty('express') + }) + + test('deps.server does not have @bsv/wallet-relay (client-only dep)', () => { + const result = planPlacement(monorepoConfig, bothCaps) + expect(result.deps.server).not.toHaveProperty('@bsv/wallet-relay') + }) + + test('deps.client does not have express (server-only dep)', () => { + const result = planPlacement(monorepoConfig, bothCaps) + expect(result.deps.client).not.toHaveProperty('express') + }) +}) + +describe('planPlacement — custom bsvDir', () => { + test('files are placed under custom bsvDir in monorepo', () => { + const result = planPlacement(customBsvDirConfig, bothCaps) + const paths = result.utilFiles.map(f => f.path) + expect(paths).toContain('client/lib/bsv/auth.ts') + expect(paths).toContain('server/lib/bsv/auth.ts') + expect(paths).toContain('client/lib/bsv/useWalletLogin.tsx') + expect(paths).toContain('server/lib/bsv/loginRoute.ts') + }) + + test('default bsvDir paths are absent when custom bsvDir is set', () => { + const result = planPlacement(customBsvDirConfig, bothCaps) + const paths = result.utilFiles.map(f => f.path) + expect(paths).not.toContain('client/src/bsv/auth.ts') + expect(paths).not.toContain('server/src/bsv/auth.ts') + }) +}) + +describe('planPlacement — glue toggle', () => { + test('glue files NOT produced when config.glue is false', () => { + const result = planPlacement({ ...monorepoConfig, glue: false }, [capWithGlue]) + expect(result.glueFiles).toHaveLength(0) + }) + + test('glue files ARE produced when config.glue is true', () => { + const result = planPlacement({ ...monorepoConfig, glue: true }, [capWithGlue]) + expect(result.glueFiles.length).toBeGreaterThan(0) + }) + + test('glue files placed at target root (NOT under bsvDir)', () => { + const result = planPlacement({ ...monorepoConfig, glue: true }, [capWithGlue]) + const paths = result.glueFiles.map(f => f.path) + // glue-entry.ts should be at client/glue-entry.ts and server/glue-entry.ts, NOT under src/bsv/ + for (const p of paths) { + expect(p).not.toContain(monorepoConfig.bsvDir) + } + expect(paths.some(p => p.endsWith('glue-entry.ts'))).toBe(true) + }) + + test('util files from capWithGlue are under bsvDir', () => { + const result = planPlacement({ ...monorepoConfig, glue: true }, [capWithGlue]) + const paths = result.utilFiles.map(f => f.path) + expect(paths.every(p => p.includes(monorepoConfig.bsvDir))).toBe(true) + }) +}) diff --git a/packages/helpers/create-bsv-app/src/__tests__/plan.test.ts b/packages/helpers/create-bsv-app/src/__tests__/plan.test.ts new file mode 100644 index 000000000..c273953ce --- /dev/null +++ b/packages/helpers/create-bsv-app/src/__tests__/plan.test.ts @@ -0,0 +1,104 @@ +// src/__tests__/plan.test.ts +import { describe, expect, test } from '@jest/globals' +import { planPlacement } from '../engine' +import { walletConnect } from '../capabilities/wallet-connect' +import { walletLogin } from '../capabilities/wallet-login' +import type { ProjectConfig } from '../config/model' + +const frontendConfig: ProjectConfig = { + mode: 'add', + name: 'demo', + dir: '.', + stack: { frontend: { framework: 'react', variant: 'react-ts' } }, + bsvDir: 'src/bsv', + capabilities: ['wallet-connect', 'wallet-login'], + glue: false, + packageManager: 'npm', + network: 'test' +} + +const backendConfig: ProjectConfig = { + mode: 'add', + name: 'demo', + dir: '.', + stack: { backend: { framework: 'express' } }, + bsvDir: 'src/bsv', + capabilities: ['wallet-connect', 'wallet-login'], + glue: false, + packageManager: 'npm', + network: 'test' +} + +// wallet-login requires wallet-connect; pass both (as resolveCapabilities would return in expand mode) +const bothCaps = [walletConnect, walletLogin] + +describe('planPlacement', () => { + test('frontend-only: shared + client files placed at root bsvDir, no server files', () => { + const result = planPlacement(frontendConfig, bothCaps) + const paths = result.utilFiles.map(f => f.path) + expect(paths).toContain('src/bsv/auth.ts') + expect(paths).toContain('src/bsv/useWalletLogin.tsx') + expect(paths).not.toContain('src/bsv/loginRoute.ts') + }) + + test('frontend-only: deps.root has @bsv/auth and @bsv/sdk', () => { + const result = planPlacement(frontendConfig, bothCaps) + expect(result.deps.root).toHaveProperty('@bsv/auth') + expect(result.deps.root).toHaveProperty('@bsv/sdk') + }) + + test('backend-only: shared + server files placed at root bsvDir, no client files', () => { + const result = planPlacement(backendConfig, bothCaps) + const paths = result.utilFiles.map(f => f.path) + expect(paths).toContain('src/bsv/auth.ts') + expect(paths).toContain('src/bsv/loginRoute.ts') + expect(paths).not.toContain('src/bsv/useWalletLogin.tsx') + }) + + test('throws on a file conflict (two caps emit the same path with different content)', () => { + // two fake caps emit the same path with different content + const capA = { + id: 'a', + title: 'A', + description: 'a', + roles: ['shared' as const], + files: () => ({ shared: [{ path: 'clash.ts', content: 'content-from-a' }] }), + npmDependencies: () => ({}), + agentsSection: () => '' + } + const capB = { + id: 'b', + title: 'B', + description: 'b', + roles: ['shared' as const], + files: () => ({ shared: [{ path: 'clash.ts', content: 'content-from-b' }] }), + npmDependencies: () => ({}), + agentsSection: () => '' + } + expect(() => planPlacement(frontendConfig, [capA, capB])).toThrow(/file conflict/i) + }) + + test('deduplicates when two caps emit same path with identical content', () => { + const capA = { + id: 'a', + title: 'A', + description: 'a', + roles: ['shared' as const], + files: () => ({ shared: [{ path: 'dup.ts', content: 'same-content' }] }), + npmDependencies: () => ({}), + agentsSection: () => '' + } + const capC = { + id: 'c', + title: 'C', + description: 'c', + roles: ['shared' as const], + files: () => ({ shared: [{ path: 'dup.ts', content: 'same-content' }] }), + npmDependencies: () => ({}), + agentsSection: () => '' + } + const result = planPlacement(frontendConfig, [capA, capC]) + const paths = result.utilFiles.map(f => f.path) + expect(paths.filter(p => p === 'src/bsv/dup.ts')).toHaveLength(1) + }) +}) diff --git a/packages/helpers/create-bsv-app/src/__tests__/registry-consistency.test.ts b/packages/helpers/create-bsv-app/src/__tests__/registry-consistency.test.ts new file mode 100644 index 000000000..ca07e2634 --- /dev/null +++ b/packages/helpers/create-bsv-app/src/__tests__/registry-consistency.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, test } from '@jest/globals' +import { registry, resolveCapabilities } from '../registry' +import type { Role } from '../types' + +const ctx = { name: 'd', network: 'test' as const, bsvDir: 'src/bsv', stack: { frontend: { framework: 'react' as const, variant: 'react-ts' }, backend: { framework: 'express' as const } }, layout: 'monorepo' as const } + +describe('registry consistency', () => { + test('ids are unique', () => { + const ids = registry.map(c => c.id) + expect(new Set(ids).size).toBe(ids.length) + }) + test('every requires resolves', () => { + for (const c of registry) { + for (const r of c.requires ?? []) { + expect(registry.some(x => x.id === r)).toBe(true) + } + } + expect(() => resolveCapabilities(registry.map(c => c.id))).not.toThrow() + }) + test('roles cover the keys each capability emits', () => { + for (const c of registry) { + const keys = new Set([ + ...Object.keys(c.files(ctx)), + ...Object.keys(c.glue?.(ctx) ?? {}), + ...Object.keys(c.npmDependencies(ctx)) + ]) + for (const k of keys) { + expect(c.roles).toContain(k as Role) + } + } + }) +}) diff --git a/packages/helpers/create-bsv-app/src/__tests__/registry.test.ts b/packages/helpers/create-bsv-app/src/__tests__/registry.test.ts new file mode 100644 index 000000000..b78008aba --- /dev/null +++ b/packages/helpers/create-bsv-app/src/__tests__/registry.test.ts @@ -0,0 +1,39 @@ +// src/__tests__/registry.test.ts +import { describe, expect, test } from '@jest/globals' +import { getCapability, listCapabilities, resolveCapabilities } from '../registry' + +describe('capability registry', () => { + test('lists the wallet-login capability', () => { + expect(listCapabilities().map(c => c.id)).toContain('wallet-login') + }) + + test('getCapability returns a capability with required fields', () => { + const c = getCapability('wallet-login') + expect(c).toBeDefined() + expect(c?.title.length).toBeGreaterThan(0) + expect(Array.isArray(c?.roles)).toBe(true) + expect(typeof c?.files).toBe('function') + expect(typeof c?.npmDependencies).toBe('function') + expect(typeof c?.agentsSection).toBe('function') + }) + + test('getCapability returns undefined for unknown id', () => { + expect(getCapability('nope')).toBeUndefined() + }) +}) + +describe('resolveCapabilities expandRequires', () => { + // Item 8: expandRequires:false — no auto-pull of wallet-connect + test('expandRequires:false returns only the named id (wallet-login, no wallet-connect pulled)', () => { + expect(resolveCapabilities(['wallet-login'], { expandRequires: false }).map(c => c.id)).toEqual(['wallet-login']) + }) + + // Item 8: default (expand) pulls wallet-connect first, then wallet-login + test('default expand: resolveCapabilities wallet-login includes wallet-connect (base before variant)', () => { + const ids = resolveCapabilities(['wallet-login']).map(c => c.id) + expect(ids).toContain('wallet-connect') + expect(ids).toContain('wallet-login') + // wallet-connect (base) must appear before wallet-login (variant) + expect(ids.indexOf('wallet-connect')).toBeLessThan(ids.indexOf('wallet-login')) + }) +}) diff --git a/packages/helpers/create-bsv-app/src/__tests__/rerun.test.ts b/packages/helpers/create-bsv-app/src/__tests__/rerun.test.ts new file mode 100644 index 000000000..467f86a23 --- /dev/null +++ b/packages/helpers/create-bsv-app/src/__tests__/rerun.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, test, beforeEach, afterEach } from '@jest/globals' +import { mkdtempSync, rmSync, readFileSync, existsSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { run } from '../cli' +import { readValidManifest } from '../config/project-manifest' +import type { RunCommand } from '../scaffold/base-scaffolder' + +let dir: string +beforeEach(() => { dir = mkdtempSync(join(tmpdir(), 'cba-re-')) }) +afterEach(() => { rmSync(dir, { recursive: true, force: true }) }) + +describe('re-run add flow', () => { + // Items 3 & 4: use wallet-login layout; strict dedup assertion + loginRoute/useWalletLogin + AGENTS.md + test('second run reuses locked stack, unions capabilities, regenerates AGENTS.md (wallet-connect + wallet-login)', async () => { + // First run: scaffold new project with wallet-connect + wallet-login (monorepo with backend) + const fake: RunCommand = () => {} + await run( + ['--dir', dir, '--mode', 'new', '--name', 'demo', '--backend', 'express', '--frontend', 'react', '--capabilities', 'wallet-connect,wallet-login', '--yes'], + undefined, + { runCommand: fake } + ) + const first = readValidManifest(dir) + if (first == null) throw new Error('manifest not written after first run') + expect(first.stack.backend?.framework).toBe('express') + // monorepo: auth.ts lands in both client/ and server/ bsvDir prefixes + expect(existsSync(join(dir, 'client/src/bsv/auth.ts'))).toBe(true) + // loginRoute.ts is placed under server bsvDir (monorepo) + expect(existsSync(join(dir, 'server/src/bsv/loginRoute.ts'))).toBe(true) + // useWalletLogin.tsx is placed under client bsvDir (monorepo) + expect(existsSync(join(dir, 'client/src/bsv/useWalletLogin.tsx'))).toBe(true) + + // Second run: --yes add (mode auto-detected from manifest), same capabilities — union with dedup + await run( + ['--dir', dir, '--capabilities', 'wallet-connect,wallet-login', '--yes'] + ) + + const after = readValidManifest(dir) + if (after == null) throw new Error('manifest not written after second run') + expect(after.stack.backend?.framework).toBe('express') + // Item 3: STRICT dedup assertion — no duplicates after re-run + expect(after.capabilities).toEqual(['wallet-connect', 'wallet-login']) + const agentsMd = readFileSync(join(dir, 'AGENTS.md'), 'utf8') + // Item 4: both wallet-connect and wallet-login sections appear + expect(agentsMd).toContain('## wallet-connect') + expect(agentsMd).toContain('## wallet-login') + }) + + test('second run via provider: existing manifest passed, capabilities unioned', async () => { + const fake: RunCommand = () => {} + await run( + ['--dir', dir, '--mode', 'new', '--name', 'demo', '--backend', 'express', '--capabilities', 'wallet-connect,wallet-login', '--yes'], + undefined, + { runCommand: fake } + ) + + const provider = async (ctx: { existing: import('../config/project-manifest').ProjectManifest | null }): Promise => { + const existing = ctx.existing + if (existing == null) throw new Error('expected existing manifest') + return { + mode: 'add', + name: existing.name, + dir: '.', + stack: existing.stack, + bsvDir: existing.bsvDir, + capabilities: [...existing.capabilities], // same caps, union handled by seedDraft when using --yes, here provider owns it + glue: false, + packageManager: 'npm', + network: existing.network + } + } + await run(['--dir', dir], provider) + + const after = readValidManifest(dir) + if (after == null) throw new Error('manifest not written after second run') + expect(after.stack.backend?.framework).toBe('express') + expect(after.capabilities).toEqual(['wallet-connect', 'wallet-login']) + const agentsMd = readFileSync(join(dir, 'AGENTS.md'), 'utf8') + expect(agentsMd).toContain('## wallet-connect') + expect(agentsMd).toContain('## wallet-login') + }) +}) diff --git a/packages/helpers/create-bsv-app/src/__tests__/write.test.ts b/packages/helpers/create-bsv-app/src/__tests__/write.test.ts new file mode 100644 index 000000000..e7338eff7 --- /dev/null +++ b/packages/helpers/create-bsv-app/src/__tests__/write.test.ts @@ -0,0 +1,32 @@ +// src/__tests__/write.test.ts +import { describe, expect, test, beforeEach, afterEach } from '@jest/globals' +import { mkdtempSync, rmSync, readFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { writeFiles } from '../engine' + +let dir: string +beforeEach(() => { dir = mkdtempSync(join(tmpdir(), 'cba-')) }) +afterEach(() => { rmSync(dir, { recursive: true, force: true }) }) + +describe('writeFiles', () => { + test('writes files and creates nested directories', () => { + const res = writeFiles([{ path: 'src/bsv/auth.ts', content: 'hi' }], dir) + expect(res.written).toEqual(['src/bsv/auth.ts']) + expect(readFileSync(join(dir, 'src/bsv/auth.ts'), 'utf8')).toBe('hi') + }) + + test('skips existing files on a second run (idempotent)', () => { + writeFiles([{ path: 'a.txt', content: 'one' }], dir) + const res = writeFiles([{ path: 'a.txt', content: 'two' }], dir) + expect(res.skipped).toEqual(['a.txt']) + expect(readFileSync(join(dir, 'a.txt'), 'utf8')).toBe('one') + }) + + test('overwrites when force is set', () => { + writeFiles([{ path: 'a.txt', content: 'one' }], dir) + const res = writeFiles([{ path: 'a.txt', content: 'two' }], dir, { force: true }) + expect(res.written).toEqual(['a.txt']) + expect(readFileSync(join(dir, 'a.txt'), 'utf8')).toBe('two') + }) +}) diff --git a/packages/helpers/create-bsv-app/src/agents-md.ts b/packages/helpers/create-bsv-app/src/agents-md.ts new file mode 100644 index 000000000..95ee4fc8f --- /dev/null +++ b/packages/helpers/create-bsv-app/src/agents-md.ts @@ -0,0 +1,85 @@ +// src/agents-md.ts +import type { ProjectConfig } from './config/model.js' +import { layoutOf } from './config/model.js' +import type { Capability, CapabilityContext } from './types.js' +import { planPlacement } from './engine.js' +import { newBuilder, routeImports, routeJsx } from './scaffold/base-app.js' + +function installBlock (label: string, deps: Record): string { + const names = Object.keys(deps) + if (names.length === 0) return '' + const ranges = Object.entries(deps).map(([n, r]) => ` ${n}@${r}`).join('\n') + const head = label.length > 0 ? `### ${label}\n\n` : '' + const cmd = label.length > 0 ? `cd ${label.replace(/\/$/, '')} && npm i` : 'npm i' + return `${head}Dependencies are already in \`package.json\` — just install:\n\n\`\`\`\n${cmd}\n\`\`\`\n\nIncluded:\n${ranges}\n\n` +} + +function wiringSection (config: ProjectConfig, capabilities: Capability[], ctx: CapabilityContext): string { + const builder = newBuilder() + for (const cap of capabilities) cap.baseEdits?.({ builder, ctx }) + + const isManual = config.mode !== 'new' || !config.glue + + if (!isManual) { + return '## Wiring\n\nBase files (`main.tsx`, `App.tsx`, `server`) were wired automatically.\n' + } + + const blocks: string[] = [] + + // main.tsx: imports + wrap + if (builder.main.imports.length > 0 || builder.main.wraps.length > 0) { + const lines: string[] = [] + if (builder.main.imports.length > 0) { + lines.push(builder.main.imports.join('\n')) + } + if (builder.main.wraps.length > 0) { + const opens = builder.main.wraps.map(w => w.open).join('\n') + const closes = builder.main.wraps.map(w => w.close).reverse().join('\n') + lines.push(`// Wrap in src/main.tsx:\n${opens}\n\n${closes}`) + } + blocks.push(`### \`src/main.tsx\`\n\n\`\`\`tsx\n${lines.join('\n')}\n\`\`\``) + } + + // App.tsx: route imports + JSX + if (builder.app.routes.length > 0 || builder.app.imports.length > 0) { + const lines: string[] = [] + const allImports = [...builder.app.imports] + const generatedImports = routeImports(builder.app.routes) + if (generatedImports.length > 0) allImports.push(generatedImports) + if (allImports.length > 0) lines.push(allImports.join('\n')) + const jsx = routeJsx(builder.app.routes) + if (jsx.length > 0) lines.push(`// Add inside in src/App.tsx:\n${jsx}`) + blocks.push(`### \`src/App.tsx\`\n\n\`\`\`tsx\n${lines.join('\n')}\n\`\`\``) + } + + // server/src/index.ts: imports + routes + if (builder.server.imports.length > 0 || builder.server.routes.length > 0) { + const lines: string[] = [] + if (builder.server.imports.length > 0) lines.push(builder.server.imports.join('\n')) + if (builder.server.routes.length > 0) lines.push(`// Add after app setup in server/src/index.ts:\n${builder.server.routes.join('\n')}`) + blocks.push(`### \`server/src/index.ts\`\n\n\`\`\`ts\n${lines.join('\n')}\n\`\`\``) + } + + if (blocks.length === 0) return '' + + let out = `## Wiring (manual)\n\nAdd-mode or \`--no-glue\`: paste these snippets into the relevant base files.\n\n${blocks.join('\n\n')}\n` + + if (builder.server.routes.length > 0) { + out += '\n> **`SERVER_PRIVATE_KEY`** — add to `.env`: the server template initialises `serverWallet` from this variable (e.g. `SERVER_PRIVATE_KEY=`).\n' + } + + return out +} + +export function renderAgentsMd (config: ProjectConfig, capabilities: Capability[]): string { + const layout = layoutOf(config.stack) + const ctx: CapabilityContext = { name: config.name, network: config.network, bsvDir: config.bsvDir, stack: config.stack, layout } + const { deps } = planPlacement(config, capabilities) + + const header = `# ${config.name} — agent guide\n\nScaffolded by \`create-bsv-app\` (layout: **${layout}**, network: **${config.network}**). BSV capabilities live under \`${config.bsvDir}\`. Re-run \`npx create-bsv-app\` inside this folder to add more capabilities.\n\n` + let depsSection = '## Install dependencies\n\n' + depsSection += layout === 'monorepo' ? installBlock('client/', deps.client) + installBlock('server/', deps.server) : installBlock('', deps.root) + const wiring = wiringSection(config, capabilities, ctx) + const sections = capabilities.map(c => c.agentsSection(ctx).trimEnd()) + return header + depsSection + (wiring.length > 0 ? wiring + '\n' : '') + sections.join('\n\n') + '\n' +} diff --git a/packages/helpers/create-bsv-app/src/capabilities/__tests__/signed-requests.test.ts b/packages/helpers/create-bsv-app/src/capabilities/__tests__/signed-requests.test.ts new file mode 100644 index 000000000..5425cb265 --- /dev/null +++ b/packages/helpers/create-bsv-app/src/capabilities/__tests__/signed-requests.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, test } from '@jest/globals' +import { signedRequests } from '../signed-requests' +import { newBuilder } from '../../scaffold/base-app' + +const ctx = { name: 'demo', network: 'test' as const, bsvDir: 'src/bsv', stack: {}, layout: 'monorepo' as const } + +describe('signed-requests (variant)', () => { + test('requires wallet-connect', () => { + expect(signedRequests.requires).toEqual(['wallet-connect']) + expect(signedRequests.roles).toEqual(['client', 'server']) + }) + test('client helper binds proof to { action, body }', () => { + const client = signedRequests.files(ctx).client ?? [] + expect(client.map(f => f.path).sort()).toEqual(['SignedRequestDemo.tsx', 'signedRequest.ts', 'useSignedRequest.ts']) + const helper = client.find(f => f.path === 'signedRequest.ts') + expect(helper?.content).toContain('createAuthProof') + expect(helper?.content).toContain('body') + }) + test('server verify is a framework-agnostic function (no express import)', () => { + const server = signedRequests.files(ctx).server ?? [] + const verify = server.find(f => f.path === 'verifySignedRequest.ts') + expect(verify?.content).toContain('verifyAuthProof') + expect(verify?.content).not.toContain("from 'express'") + }) + test('client files include SignedRequestDemo.tsx', () => { + const client = signedRequests.files(ctx).client ?? [] + const paths = client.map(f => f.path) + expect(paths).toContain('SignedRequestDemo.tsx') + }) + test('counterparty auto-resolves: hook serverIdentityKey is optional, demo needs no key', () => { + const client = signedRequests.files(ctx).client ?? [] + const hook = client.find(f => f.path === 'useSignedRequest.ts') + const demo = client.find(f => f.path === 'SignedRequestDemo.tsx') + expect(hook?.content).toContain('getServerIdentity') + expect(hook?.content).toContain('serverIdentityKey?: string') + expect(demo?.content).toContain('useSignedRequest()') + expect(demo?.content).not.toContain("SERVER_IDENTITY_KEY = ''") + }) + test('baseEdits adds route descriptor and server verify route', () => { + const builder = newBuilder() + signedRequests.baseEdits?.({ builder, ctx }) + expect(builder.app.routes).toContainEqual({ path: '/signed-demo', component: 'SignedRequestDemo', importPath: './bsv/SignedRequestDemo', label: 'Signed request demo' }) + expect(builder.server.routes.join()).toContain('verifySignedRequest') + }) + test('demo page renders a removable step-by-step activity log', () => { + const page = (signedRequests.files(ctx).client ?? []).find(f => f.path === 'SignedRequestDemo.tsx') + expect(page?.content).toContain('demo activity log (safe to delete)') + expect(page?.content).toContain('Signing a request proof') + }) + test('agentsSection has the How it works / Future integrations structure', () => { + const md = signedRequests.agentsSection(ctx) + expect(md).toContain('### How it works') + expect(md).toContain('### Future integrations') + }) +}) diff --git a/packages/helpers/create-bsv-app/src/capabilities/__tests__/wallet-connect.test.ts b/packages/helpers/create-bsv-app/src/capabilities/__tests__/wallet-connect.test.ts new file mode 100644 index 000000000..33a43767f --- /dev/null +++ b/packages/helpers/create-bsv-app/src/capabilities/__tests__/wallet-connect.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, test } from '@jest/globals' +import { walletConnect } from '../wallet-connect' +import { newBuilder } from '../../scaffold/base-app' + +const ctx = { name: 'demo', network: 'test' as const, bsvDir: 'src/bsv', stack: { frontend: { framework: 'react' as const, variant: 'react-ts' } }, layout: 'frontend-only' as const } + +describe('wallet-connect', () => { + test('id + defaultSelected + roles', () => { + expect(walletConnect.id).toBe('wallet-connect') + expect(walletConnect.defaultSelected).toBe(true) + expect(walletConnect.roles).toEqual(['shared', 'client']) + }) + test('shared helper is the @bsv/auth proof primitive (object-arg API)', () => { + const shared = walletConnect.files(ctx).shared ?? [] + const auth = shared.find(f => f.path === 'auth.ts') + expect(auth).toBeDefined() + expect(auth?.content).toContain('AuthProofClient') + expect(auth?.content).toContain('createAuthProof({') // object-arg, NOT positional + expect(auth?.content).not.toMatch(/createAuthProof\(\s*wallet\s*,/) // guard the old positional bug + }) + test('client gets acquisition helper and all three context files (bare paths, always emitted)', () => { + const client = walletConnect.files(ctx).client ?? [] + const paths = client.map(f => f.path) + expect(paths).toContain('walletAcquisition.ts') + expect(paths).toContain('WalletConnectionContext.tsx') + expect(paths).toContain('WalletContext.tsx') + expect(paths).toContain('WalletProviders.tsx') + }) + test('ships a shared bsv.css theme (accent #2196F3) imported by WalletProviders', () => { + const client = walletConnect.files(ctx).client ?? [] + const css = client.find(f => f.path === 'bsv.css') + expect(css?.content).toContain('--bsv-accent: #2196F3') + expect(css?.content).toContain('.bsv-page') + const providers = client.find(f => f.path === 'WalletProviders.tsx') + expect(providers?.content).toContain("import './bsv.css'") + }) + test('client ships serverIdentity helper that fetches the baseline /api/identity route', () => { + const client = walletConnect.files(ctx).client ?? [] + const helper = client.find(f => f.path === 'serverIdentity.ts') + expect(helper).toBeDefined() + expect(helper?.content).toContain('export async function getServerIdentity') + expect(helper?.content).toContain('/api/identity') + }) + test('glue is undefined (contexts moved to core files)', () => { + expect(walletConnect.glue).toBeUndefined() + }) + test('baseEdits wraps App in WalletProviders (assembler path)', () => { + const b = newBuilder() + walletConnect.baseEdits?.({ builder: b, ctx }) + expect(b.main.imports.join()).toContain('WalletProviders') + expect(b.main.wraps).toEqual([{ open: '', close: '' }]) + }) + test('deps name the right packages', () => { + expect(Object.keys(walletConnect.npmDependencies(ctx).shared ?? {})).toContain('@bsv/auth') + expect(Object.keys(walletConnect.npmDependencies(ctx).shared ?? {})).toContain('@bsv/sdk') + const client = walletConnect.npmDependencies(ctx).client ?? {} + expect(Object.keys(client)).toEqual(expect.arrayContaining(['@bsv/wallet-relay', 'react'])) + expect(Object.keys(client)).not.toContain('@bsv/sdk') + }) + test('wallet-connect provides ConnectWallet + client config and only main.* baseEdits (Home is a generated base file)', () => { + const ctx2 = { name: 'd', network: 'test' as const, bsvDir: 'src/bsv', stack: { frontend: { framework: 'react' as const, variant: 'react-ts' } }, layout: 'frontend-only' as const } + const client = (walletConnect.files(ctx2).client ?? []).map(f => f.path) + expect(client).toEqual(expect.arrayContaining(['ConnectWallet.tsx', 'config.ts', 'WalletContext.tsx'])) + expect(client).not.toContain('Home.tsx') // Home is assembled from HOME_TEMPLATE, not a capability file + const b = newBuilder() + walletConnect.baseEdits?.({ builder: b, ctx: ctx2 }) + expect(b.main.wraps).toEqual([{ open: '', close: '' }]) + expect(b.main.imports.join()).toContain('WalletProviders') + expect(b.app.routes).toEqual([]) // route-free toolkit + expect(b.server.routes).toEqual([]) + expect(walletConnect.npmDependencies(ctx2).shared).toHaveProperty('@bsv/sdk') + expect(walletConnect.npmDependencies(ctx2).client).toHaveProperty('react-router-dom') + }) + test('WalletContext is a connect state machine (connect/connectMobile/cancel/status)', () => { + const wc = (walletConnect.files({ name: 'd', network: 'test', bsvDir: 'src/bsv', stack: {}, layout: 'frontend-only' } as any).client ?? []).find(f => f.path === 'WalletContext.tsx') + for (const s of ['connect', 'connectMobile', 'cancel', 'status']) expect(wc?.content).toContain(s) + }) + test('agentsSection has the How it works / How it\'s used / Future integrations structure', () => { + const md = walletConnect.agentsSection(ctx) + expect(md).toContain('### How it works') + expect(md).toContain("### How it's used") + expect(md).toContain('### Future integrations') + }) +}) diff --git a/packages/helpers/create-bsv-app/src/capabilities/__tests__/wallet-login.test.ts b/packages/helpers/create-bsv-app/src/capabilities/__tests__/wallet-login.test.ts new file mode 100644 index 000000000..b53822d4a --- /dev/null +++ b/packages/helpers/create-bsv-app/src/capabilities/__tests__/wallet-login.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, test } from '@jest/globals' +import { walletLogin } from '../wallet-login' +import { newBuilder } from '../../scaffold/base-app' + +const ctx = { name: 'd', network: 'test' as const, bsvDir: 'src/bsv', stack: {}, layout: 'monorepo' as const } + +describe('wallet-login', () => { + test('requires wallet-connect; roles client + server', () => { + expect(walletLogin.requires).toEqual(['wallet-connect']) + expect(walletLogin.glue).toBeUndefined() + expect(walletLogin.defaultSelected).toBeUndefined() + expect(walletLogin.roles).toEqual(['client', 'server']) + }) + test('client files include WalletLogin.tsx and useWalletLogin.tsx', () => { + const client = walletLogin.files(ctx).client ?? [] + const paths = client.map(f => f.path) + expect(paths).toContain('WalletLogin.tsx') + expect(paths).toContain('useWalletLogin.tsx') + }) + test('client hook uses useWallet + the shared auth helper, action login', () => { + const client = walletLogin.files(ctx).client ?? [] + const hook = client.find(f => f.path === 'useWalletLogin.tsx') + expect(hook?.content).toContain('useWallet') + expect(hook?.content).toContain('createAuthProof') + expect(hook?.content).toContain("'login'") + }) + test('counterparty is auto-resolved (no hard-coded SERVER_IDENTITY_KEY placeholder)', () => { + const client = walletLogin.files(ctx).client ?? [] + const page = client.find(f => f.path === 'WalletLogin.tsx') + const hook = client.find(f => f.path === 'useWalletLogin.tsx') + expect(page?.content).toContain('getServerIdentity') + expect(page?.content).not.toContain("SERVER_IDENTITY_KEY = ''") + expect(hook?.content).toContain('getServerIdentity') + expect(hook?.content).toContain('serverIdentityKey?: string') // now optional + }) + test('server route verifies via the shared auth helper', () => { + const server = walletLogin.files(ctx).server ?? [] + const route = server.find(f => f.path === 'loginRoute.ts') + expect(route?.content).toContain('verifyAuthProof') + expect(route?.content).toContain("action: 'login'") + }) + test('wallet-login adds a WalletLogin page + route descriptor + server route via baseEdits', () => { + expect((walletLogin.files(ctx).client ?? []).map(f => f.path)).toContain('WalletLogin.tsx') + const b = newBuilder() + walletLogin.baseEdits?.({ builder: b, ctx }) + expect(b.app.routes).toContainEqual({ path: '/login', component: 'WalletLogin', importPath: './bsv/WalletLogin', label: 'Wallet login' }) + expect(b.server.routes.join()).toContain('/api/login') + expect(b.server.imports.join()).toContain('loginRoute') + }) + test('login page renders a removable step-by-step activity log', () => { + const page = (walletLogin.files(ctx).client ?? []).find(f => f.path === 'WalletLogin.tsx') + expect(page?.content).toContain('demo activity log (safe to delete)') + expect(page?.content).toContain('Signing a login proof') + expect(page?.content).toContain('the server trusts this identity') + }) + test('agentsSection has the How it works / How it\'s used / Future integrations structure + JWT session sketch', () => { + const md = walletLogin.agentsSection(ctx) + expect(md).toContain('### How it works') + expect(md).toContain("### How it's used") + expect(md).toContain('### Future integrations') + expect(md).toContain('SignJWT') // the going-further JWT snippet + }) +}) diff --git a/packages/helpers/create-bsv-app/src/capabilities/signed-requests.ts b/packages/helpers/create-bsv-app/src/capabilities/signed-requests.ts new file mode 100644 index 000000000..ce6d0a18a --- /dev/null +++ b/packages/helpers/create-bsv-app/src/capabilities/signed-requests.ts @@ -0,0 +1,146 @@ +import type { Capability, CapabilityContext, BaseBuilder } from '../types.js' +import { bsvImport } from '../scaffold/base-app.js' + +const SIGNED_REQUEST = `// Create a signed request: an @bsv/auth proof bound to a route (action) + body. +import type { WalletInterface } from '@bsv/sdk' +import { createAuthProof, type AuthProof, type RequestBody } from './auth.js' + +export async function createSignedRequest ( + wallet: WalletInterface, + opts: { serverIdentityKey: string, action: string, body?: RequestBody } +): Promise { + return await createAuthProof(wallet, { counterparty: opts.serverIdentityKey, action: opts.action, body: opts.body }) +} +` + +const USE_SIGNED_REQUEST = `// Hook: signedFetch attaches a proof bound to the route + JSON body. +import { useCallback } from 'react' +import { useWallet } from './WalletContext.js' +import { createSignedRequest } from './signedRequest.js' +import { getServerIdentity } from './serverIdentity.js' +import { API_BASE_URL } from './config.js' +import type { RequestBody } from './auth.js' + +// serverIdentityKey is optional: when omitted it's fetched from GET /api/identity. +export function useSignedRequest (serverIdentityKey?: string) { + const { wallet } = useWallet() + const signedFetch = useCallback(async (url: string, opts: { action: string, body?: RequestBody }): Promise => { + if (wallet === null) throw new Error('connect a wallet first') + const counterparty = serverIdentityKey ?? await getServerIdentity() + const proof = await createSignedRequest(wallet, { serverIdentityKey: counterparty, action: opts.action, body: opts.body }) + return await fetch(API_BASE_URL + url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ proof, body: opts.body }) + }) + }, [wallet, serverIdentityKey]) + return { signedFetch, connected: wallet !== null } +} +` + +const SIGNED_REQUEST_DEMO = `import { useState } from 'react' +import { Link } from 'react-router-dom' +import { ConnectWallet } from './ConnectWallet.js' +import { useSignedRequest } from './useSignedRequest.js' + +export function SignedRequestDemo () { + const { signedFetch, connected } = useSignedRequest() + const [result, setResult] = useState(null) + const [error, setError] = useState(null) + // --- demo activity log (safe to delete) --- + const [log, setLog] = useState([]) + const step = (m: string): void => setLog(l => [...l, m]) + // --- end demo activity log --- + const send = async () => { + setError(null); setResult(null); setLog([]) + try { + step('Signing a request proof (action: echo) bound to the body…') + step('POST /api/echo — sending { proof, body }') + const res = await signedFetch('/api/echo', { action: 'echo', body: { hello: 'world' } }) + if (!res.ok) { step('✗ Server rejected the request (' + String(res.status) + ')'); setError('request failed: ' + String(res.status)); return } + step('✓ Signature valid — server processed the request') + setResult(await res.json()) + } catch (e) { step('✗ ' + String(e)); setError(String(e)) } + } + return ( +
+ ← Back to home +

Signed Request Demo

+

Authenticate a single API call: sign the request with your wallet, verify it server-side.

+ + {connected && } + {result != null &&
{JSON.stringify(result, null, 2)}
} + {error != null &&

{error}

} + {/* --- demo activity log (safe to delete) --- */} + {log.length > 0 && ( +
    + {log.map((m, i) =>
  1. {m}
  2. )} +
+ )} + {/* --- end demo activity log --- */} +
+ ) +} +` + +const VERIFY = `// Framework-agnostic verification of a signed request. Works in Express, Next API +// routes, Fastify — it's a plain function. Pass your own single-use nonce store. +import { verifyAuthProof, type AuthProof, type RequestBody } from './auth.js' + +export async function verifySignedRequest ( + serverWallet: { verifySignature: (args: any) => Promise<{ valid: boolean }> }, + proof: AuthProof, + opts: { action: string, body?: RequestBody }, + consumeNonce: (nonce: string, expiresAt: Date) => boolean | Promise +): Promise<{ valid: boolean, identityKey?: string, error?: string }> { + return await verifyAuthProof(serverWallet, proof, { action: opts.action, body: opts.body }, consumeNonce) +} +` + +function agentsSection (_ctx: CapabilityContext): string { + return `## signed-requests + +Authenticate individual API calls: sign a proof bound to a route (\`action\`) + request \`body\`, send it with the request, verify it server-side. Same proof primitive as login, plus a body — one round-trip, no handshake, framework-agnostic. + +### How it works +- For each call the client signs a proof over \`{ counterparty: serverIdentity, action, body }\` and sends \`{ proof, body }\` to the route. +- The server re-derives the same binding and verifies the signature (and a single-use nonce) before trusting the caller's \`identityKey\`. Because the proof is bound to the exact action + body, it can't be replayed against another route or with a tampered payload. +- It's stateless — there's no session; every request carries its own authentication. The demo page narrates the steps and shows the server's JSON reply. + +### How it's used +- \`signedRequest.ts\` / \`useSignedRequest.ts\` (client) — \`const { signedFetch } = useSignedRequest()\`; \`signedFetch('/api/thing', { action: 'thing', body })\`. Counterparty auto-fetched; pass \`useSignedRequest(serverIdentityKey)\` to pin it. +- \`SignedRequestDemo.tsx\` (client page) — interactive demo at \`/signed-demo\`: connect, send a signed echo to \`/api/echo\`, watch the steps + JSON result. +- \`verifySignedRequest.ts\` (server) — \`verifySignedRequest(serverWallet, proof, { action, body }, consumeNonce)\`; call it from any backend (Express/Next/Fastify) before trusting \`identityKey\`. + +### Future integrations +- Back the \`consumeNonce\` callback with Redis/DB so replay protection holds across processes and restarts. +- Gate real endpoints: verify, then authorize the \`identityKey\` (allow-list, roles, ownership checks). +- Bind extra context into the \`body\` (timestamps, resource ids) for tighter, per-resource authentication. +` +} + +export const signedRequests: Capability = { + id: 'signed-requests', + title: 'Signed requests (per-call BRC-103 auth)', + description: 'Sign API calls bound to a route + body; verify with a framework-agnostic function. Builds on wallet-connect.', + requires: ['wallet-connect'], + roles: ['client', 'server'], + files: () => ({ + client: [ + { path: 'signedRequest.ts', content: SIGNED_REQUEST }, + { path: 'useSignedRequest.ts', content: USE_SIGNED_REQUEST }, + { path: 'SignedRequestDemo.tsx', content: SIGNED_REQUEST_DEMO } + ], + server: [{ path: 'verifySignedRequest.ts', content: VERIFY }] + }), + baseEdits: ({ builder, ctx }: { builder: BaseBuilder, ctx: CapabilityContext }) => { + builder.app.routes.push({ path: '/signed-demo', component: 'SignedRequestDemo', importPath: bsvImport(ctx, 'SignedRequestDemo'), label: 'Signed request demo' }) + builder.server.imports.push(`import { verifySignedRequest } from '${bsvImport(ctx, 'verifySignedRequest.js')}'`) + builder.server.routes.push("app.post('/api/echo', async (req, res) => { const { proof, body } = req.body; const r = await verifySignedRequest(serverWallet, proof, { action: 'echo', body }, async () => true); res.status(r.valid ? 200 : 401).json(r) })") + }, + npmDependencies: () => ({ + client: { react: '>=18' }, + server: {} + }), + agentsSection +} diff --git a/packages/helpers/create-bsv-app/src/capabilities/wallet-connect.ts b/packages/helpers/create-bsv-app/src/capabilities/wallet-connect.ts new file mode 100644 index 000000000..811416490 --- /dev/null +++ b/packages/helpers/create-bsv-app/src/capabilities/wallet-connect.ts @@ -0,0 +1,310 @@ +// src/capabilities/wallet-connect.ts +import type { Capability, CapabilityContext, BaseBuilder } from '../types.js' +import { bsvImport } from '../scaffold/base-app.js' + +const AUTH_UTIL = `// Shared, framework-agnostic auth-proof helpers built on @bsv/auth (BRC-103). +// One primitive: sign a proof bound to { action, body? }, verify it on the server. +import { AuthProofClient, AuthProofServer, type AuthProof, type ProofSignerWallet, type RequestBody } from '@bsv/auth' + +export type { AuthProof, RequestBody } + +export async function createAuthProof ( + wallet: ProofSignerWallet, + opts: { counterparty: string, action: string, body?: RequestBody } +): Promise { + const client = new AuthProofClient() + return await client.createAuthProof({ wallet, counterparty: opts.counterparty, action: opts.action, body: opts.body }) +} + +export async function verifyAuthProof ( + serverWallet: { verifySignature: (args: any) => Promise<{ valid: boolean }> }, + proof: AuthProof, + opts: { action: string, body?: RequestBody }, + consumeNonce: (nonce: string, expiresAt: Date) => boolean | Promise +): Promise<{ valid: boolean, identityKey?: string, error?: string }> { + const server = new AuthProofServer() + return await server.verifyAuthProof({ wallet: serverWallet, proof, action: opts.action, body: opts.body, consumeNonce }) +} +` + +const ACQUISITION = `// Desktop/extension wallet acquisition via @bsv/sdk WalletClient('auto'). +import { WalletClient, type WalletInterface } from '@bsv/sdk' + +export async function connectDesktopWallet (): Promise<{ wallet: WalletInterface, identityKey: string }> { + const wallet = new WalletClient('auto') + const { authenticated } = await wallet.isAuthenticated() + if (!authenticated) throw new Error('No authenticated desktop wallet found') + const { publicKey } = await wallet.getPublicKey({ identityKey: true }) + return { wallet, identityKey: publicKey } +} +` + +const CLIENT_CONFIG = `// Centralized client configuration. Vite loads VITE_-prefixed vars from client/.env. +// Base URL of the server API. Defaults to the dev server; set VITE_API_URL in production +// (or whenever the client is served from a different origin than the API). +export const API_BASE_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:3000' +` + +const SERVER_IDENTITY = `// Fetch the server's identity public key (its wallet's identityKey) once, and cache it. +// Used as the proof \`counterparty\` for login / signed requests — the server exposes it +// at GET /api/identity (baseline route), so no key needs to be hard-coded client-side. +import { API_BASE_URL } from './config.js' + +let cached: string | null = null + +export async function getServerIdentity (endpoint = '/api/identity'): Promise { + if (cached !== null) return cached + const res = await fetch(API_BASE_URL + endpoint) + if (!res.ok) throw new Error('failed to fetch server identity: ' + String(res.status)) + const { identityKey } = await res.json() as { identityKey: string } + cached = identityKey + return identityKey +} +` + +const RELAY_CONTEXT = `// Relay-session context: wraps @bsv/wallet-relay's hook so a single relay client +// (mobile QR / remote wallet) lives above the router. Port/extend from your app as needed. +import { createContext, useContext, type ReactNode } from 'react' +import { useWalletRelayClient } from '@bsv/wallet-relay/react' + +type RelayValue = ReturnType +const Ctx = createContext(null) + +export function WalletConnectionProvider ({ children, apiUrl }: { children: ReactNode, apiUrl?: string }) { + const relay = useWalletRelayClient({ apiUrl, autoCreate: false }) + return {children} +} + +export function useWalletConnection (): RelayValue { + const v = useContext(Ctx) + if (v === null) throw new Error('useWalletConnection must be used within WalletConnectionProvider') + return v +} +` + +const WALLET_CONTEXT = `// App-wide wallet state + connect state machine (desktop-first, relay fallback). +import { createContext, useContext, useState, useCallback, useEffect, type ReactNode } from 'react' +import type { WalletInterface } from '@bsv/sdk' +import { connectDesktopWallet } from './walletAcquisition.js' +import { useWalletConnection } from './WalletConnectionContext.js' + +export type ConnectStatus = 'disconnected' | 'connecting' | 'choosing' | 'pairing' | 'connected' +interface WalletState { + wallet: WalletInterface | null + identityKey: string | null + connected: boolean + status: ConnectStatus + connect: () => Promise // desktop-first; on failure -> 'choosing' + connectMobile: () => Promise // relay QR -> 'pairing' + cancel: () => void +} +const Ctx = createContext(null) + +export function WalletProvider ({ children }: { children: ReactNode }) { + const relay = useWalletConnection() + const [wallet, setWallet] = useState(null) + const [identityKey, setIdentityKey] = useState(null) + const [status, setStatus] = useState('disconnected') + + const connect = useCallback(async () => { + setStatus('connecting') + try { + const { wallet, identityKey } = await connectDesktopWallet() + setWallet(wallet); setIdentityKey(identityKey); setStatus('connected') + } catch { + setStatus('choosing') // no desktop wallet -> show modal + } + }, []) + + const connectMobile = useCallback(async () => { + setStatus('pairing') + try { + await relay.createSession() // shows QR via relay.session.qrDataUrl + } catch { + setStatus('choosing') // relay unavailable -> back to the choice modal + } + }, [relay]) + + const cancel = useCallback(() => { relay.cancelSession?.(); setStatus('disconnected') }, [relay]) + + // bridge: when the relay session connects, adopt its wallet + useEffect(() => { + if (relay.session?.status === 'connected' && relay.wallet != null && wallet == null) { + const w = relay.wallet as unknown as WalletInterface + w.getPublicKey({ identityKey: true }).then(({ publicKey }) => { + setWallet(w); setIdentityKey(publicKey); setStatus('connected') + }).catch(() => {}) + } + }, [relay.session?.status, relay.wallet, wallet]) + + return {children} +} +export function useWallet (): WalletState { + const v = useContext(Ctx) + if (v === null) throw new Error('useWallet must be used within WalletProvider') + return v +} +` + +const BSV_CSS = `/* Shared dark theme for the scaffolded BSV pages — accent #2196F3. + Imported once by WalletProviders; delete or restyle freely. */ +:root { + --bsv-accent: #2196F3; + --bsv-ink: #06121f; + --bsv-bg: #0b0e13; + --bsv-surface: #0f151c; + --bsv-surface-deep: #05070a; + --bsv-border: #2c3540; + --bsv-border-soft: #243441; + --bsv-text: #cdd4de; + --bsv-strong: #e8edf4; + --bsv-muted: #7b8694; + --bsv-green: #7fd6a0; + --bsv-err: #e06a5a; + --bsv-mono: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, monospace; +} +body { margin: 0; display: block; background: var(--bsv-bg); color: var(--bsv-text); font: 14px/1.5 system-ui, -apple-system, sans-serif; } +.bsv-page { max-width: 680px; margin: 0 auto; padding: 56px 24px 96px; } +.bsv-page h1 { margin: 0 0 8px; font: 600 26px/1.15 system-ui; color: var(--bsv-strong); } +.bsv-page > p { margin: 0 0 22px; color: var(--bsv-muted); } +.bsv-back { display: inline-block; margin-bottom: 22px; color: var(--bsv-muted); text-decoration: none; font-size: 13px; } +.bsv-back:hover { color: var(--bsv-text); } +code { font-family: var(--bsv-mono); color: var(--bsv-strong); } +.bsv-btn { height: 44px; padding: 0 20px; border: 0; border-radius: 7px; background: var(--bsv-accent); color: var(--bsv-ink); font: 600 14px/1 system-ui; cursor: pointer; } +.bsv-btn:hover { filter: brightness(1.07); } +.bsv-btn:disabled { opacity: .5; cursor: default; filter: none; } +.bsv-btn-ghost { height: 40px; padding: 0 16px; border: 1px solid var(--bsv-border); border-radius: 7px; background: transparent; color: #aab3bf; font: 500 13px/1 system-ui; cursor: pointer; } +.bsv-btn-ghost:hover { border-color: #3d4855; color: var(--bsv-text); } +.bsv-connect { margin: 20px 0; } +.bsv-connect button { height: 44px; padding: 0 20px; border: 0; border-radius: 7px; background: var(--bsv-accent); color: var(--bsv-ink); font: 600 14px/1 system-ui; cursor: pointer; } +.bsv-connect button:hover { filter: brightness(1.07); } +.bsv-connect button:disabled { opacity: .5; cursor: default; filter: none; } +.bsv-connected { display: inline-block; margin: 20px 0; padding: 10px 14px; border: 1px solid #1d3d28; border-radius: 8px; background: #0f2418; color: var(--bsv-green); font: 500 13px/1.4 system-ui; } +.bsv-page button + button, .bsv-page .bsv-btn { margin-top: 4px; } +.bsv-label { margin: 0 0 4px; font: 600 11px/1 system-ui; letter-spacing: .1em; text-transform: uppercase; color: var(--bsv-muted); } +.bsv-nav { margin-top: 28px; display: grid; gap: 9px; } +.bsv-nav a { display: flex; align-items: center; justify-content: space-between; padding: 14px 16px; border: 1px solid var(--bsv-border-soft); border-radius: 10px; background: var(--bsv-surface); color: var(--bsv-text); text-decoration: none; font: 500 14px/1 system-ui; } +.bsv-nav a:hover { border-color: var(--bsv-accent); background: rgba(33,150,243,.07); color: var(--bsv-strong); } +.bsv-log { margin: 20px 0 0; padding: 14px 16px 14px 36px; list-style: decimal; background: var(--bsv-surface-deep); border: 1px solid #1d242d; border-radius: 8px; font: 400 12.5px/1.9 var(--bsv-mono); color: #9aa6b2; } +.bsv-result { margin-top: 16px; padding: 14px 16px; background: var(--bsv-surface-deep); border: 1px solid #1d242d; border-radius: 8px; font: 400 12.5px/1.7 var(--bsv-mono); color: var(--bsv-text); white-space: pre-wrap; word-break: break-word; } +.bsv-err { margin-top: 14px; color: var(--bsv-err); font-size: 13px; } +.bsv-modal { position: fixed; inset: 0; background: rgba(4,6,9,.82); display: flex; align-items: center; justify-content: center; z-index: 50; padding: 20px; } +.bsv-modal-card { width: 100%; max-width: 380px; background: #0e141b; border: 1px solid var(--bsv-border-soft); border-radius: 14px; padding: 26px; text-align: center; box-shadow: 0 24px 70px rgba(0,0,0,.55); } +.bsv-modal-card h3 { margin: 0 0 16px; font: 600 17px/1.3 system-ui; color: var(--bsv-strong); } +.bsv-modal-card .bsv-btn, .bsv-modal-card .bsv-btn-ghost { display: block; width: 100%; margin-top: 9px; } +.bsv-modal-card a { display: inline-block; margin-top: 12px; color: var(--bsv-accent); font-size: 13px; } +.bsv-modal-card img { width: 210px; height: 210px; margin-top: 6px; border-radius: 10px; background: #fff; padding: 8px; } +` + +const PROVIDERS = `// Compose the wallet providers in the required order (relay above wallet). +import './bsv.css' +import type { ReactNode } from 'react' +import { WalletConnectionProvider } from './WalletConnectionContext.js' +import { WalletProvider } from './WalletContext.js' + +export function WalletProviders ({ children }: { children: ReactNode }) { + return ( + + {children} + + ) +} +` + +const CONNECT_WALLET = `// Connect button + desktop-fail modal (mobile QR / install link). Built on useWallet + the relay session. +import { useWallet } from './WalletContext.js' +import { useWalletConnection } from './WalletConnectionContext.js' + +const INSTALL_URL = 'https://desktop.bsvb.tech' + +export function ConnectWallet () { + const { status, identityKey, connect, connectMobile, cancel } = useWallet() + const relay = useWalletConnection() + if (status === 'connected') { + return
Connected: {identityKey?.slice(0, 16)}…
+ } + return ( +
+ + {(status === 'choosing' || status === 'pairing') && ( +
+
+ {status === 'choosing' && ( + <> +

No desktop wallet found

+ + Install a desktop wallet + + )} + {status === 'pairing' && ( + <> +

Scan with your mobile wallet

+ {relay.session?.qrDataUrl != null ? Pairing QR :

Generating code…

} + + )} + +
+
+ )} +
+ ) +} +` + +function agentsSection (_ctx: CapabilityContext): string { + return `## wallet-connect (base) + +Connect any BRC-100 wallet — desktop (\`@bsv/sdk\` \`WalletClient('auto')\`) or mobile/relay (\`@bsv/wallet-relay\`) — use it app-wide, and sign/verify the \`@bsv/auth\` proofs that \`wallet-login\` and \`signed-requests\` build on. + +### How it works +- Connecting is a small state machine: it tries the desktop/extension wallet first; if none is found it opens a modal to pair a mobile wallet over a relay (QR) or install a desktop one. The connected wallet lives in React context, reachable anywhere via \`useWallet()\`. +- The proof primitive (\`auth.ts\`) uses the wallet to sign a message bound to \`{ counterparty, action, body? }\` and verifies it server-side (BRC-103). That's identity (and request auth) without passwords or shared secrets. +- The server publishes its own identity key at \`GET /api/identity\`; the client fetches it (\`getServerIdentity()\`) to use as the proof \`counterparty\`, so no key is hard-coded anywhere. + +### How it's used +- \`auth.ts\` (shared) — \`createAuthProof(wallet, { counterparty, action, body? })\` and \`verifyAuthProof(serverWallet, proof, { action, body? }, consumeNonce)\`. +- \`config.ts\` (client) — \`API_BASE_URL\` (from \`VITE_API_URL\`, default \`http://localhost:3000\`); the server base every fetch helper targets. +- \`serverIdentity.ts\` (client) — \`getServerIdentity()\` fetches + caches the server's identity key from \`GET /api/identity\`. +- \`walletAcquisition.ts\` (client) — \`connectDesktopWallet()\`. +- \`WalletConnectionContext.tsx\` / \`WalletContext.tsx\` / \`WalletProviders.tsx\` (client) — relay session + wallet state; consume via \`useWallet()\`. +- \`ConnectWallet.tsx\` (client) — the connect button + desktop-fail modal. +- New projects (glue on): \`src/main.tsx\` wraps \`\` in \`\`, and a generated \`Home.tsx\` hub links to each installed capability's page once a wallet connects. With \`--no-glue\` / add mode: wrap your root in \`\` and build your own home. + +### Future integrations +- Persist the connection across reloads (re-probe the desktop wallet / restore the relay session on load). +- Reuse the proof primitive for any action beyond login — bind a proof to any \`{ action, body }\` (that's exactly what \`signed-requests\` does). +- Layer identity certificates (BRC-52/103) on top of the raw identity key when you need verified attributes, not just a public key. +` +} + +export const walletConnect: Capability = { + id: 'wallet-connect', + title: 'Wallet connect (desktop + relay, app-wide context)', + description: 'Base: connect any BRC-100 wallet (desktop or mobile/relay) and use it across the app, plus the @bsv/auth proof primitive.', + roles: ['shared', 'client'], + defaultSelected: true, + files: () => ({ + shared: [{ path: 'auth.ts', content: AUTH_UTIL }], + client: [ + { path: 'walletAcquisition.ts', content: ACQUISITION }, + { path: 'serverIdentity.ts', content: SERVER_IDENTITY }, + { path: 'WalletConnectionContext.tsx', content: RELAY_CONTEXT }, + { path: 'WalletContext.tsx', content: WALLET_CONTEXT }, + { path: 'WalletProviders.tsx', content: PROVIDERS }, + { path: 'ConnectWallet.tsx', content: CONNECT_WALLET }, + { path: 'config.ts', content: CLIENT_CONFIG }, + { path: 'bsv.css', content: BSV_CSS } + ] + }), + baseEdits: ({ builder, ctx }: { builder: BaseBuilder, ctx: CapabilityContext }) => { + builder.main.imports.push(`import { WalletProviders } from '${bsvImport(ctx, 'WalletProviders')}'`) + builder.main.wraps.push({ open: '', close: '' }) + }, + npmDependencies: () => ({ + shared: { '@bsv/auth': '^0.1.0', '@bsv/sdk': '^2.1.0' }, + client: { '@bsv/wallet-relay': '^0.2.0', react: '>=18', 'react-router-dom': '^7.0.0' } + }), + agentsSection +} diff --git a/packages/helpers/create-bsv-app/src/capabilities/wallet-login.ts b/packages/helpers/create-bsv-app/src/capabilities/wallet-login.ts new file mode 100644 index 000000000..f712016c4 --- /dev/null +++ b/packages/helpers/create-bsv-app/src/capabilities/wallet-login.ts @@ -0,0 +1,161 @@ +// src/capabilities/wallet-login.ts +import type { Capability, CapabilityContext, BaseBuilder } from '../types.js' +import { bsvImport } from '../scaffold/base-app.js' + +const WALLET_LOGIN_PAGE = `import { useState } from 'react' +import { Link } from 'react-router-dom' +import { ConnectWallet } from './ConnectWallet.js' +import { useWallet } from './WalletContext.js' +import { createAuthProof } from './auth.js' +import { getServerIdentity } from './serverIdentity.js' +import { API_BASE_URL } from './config.js' + +export function WalletLogin () { + const { wallet, connected, identityKey } = useWallet() + const [result, setResult] = useState(null) + const [error, setError] = useState(null) + // --- demo activity log (safe to delete) --- + const [log, setLog] = useState([]) + const step = (m: string): void => setLog(l => [...l, m]) + // --- end demo activity log --- + const login = async () => { + setError(null); setResult(null); setLog([]) + if (wallet == null) return + try { + step('Fetching the server identity (GET /api/identity)…') + const counterparty = await getServerIdentity() + step('Server identity: ' + counterparty.slice(0, 16) + '…') + step('Signing a login proof with your wallet (action: login)…') + const proof = await createAuthProof(wallet, { counterparty, action: 'login' }) + step('POST /api/login — sending the proof to the server') + const res = await fetch(API_BASE_URL + '/api/login', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(proof) }) + if (!res.ok) { step('✗ Server rejected the proof (' + String(res.status) + ')'); setError('login failed: ' + String(res.status)); return } + const data = await res.json() + step('✓ Proof valid — the server trusts this identity') + setResult(data.identityKey ?? identityKey) + } catch (e) { step('✗ ' + String(e)); setError(String(e)) } + } + return ( +
+ ← Back to home +

Login

+

Prove your identity to the server with your wallet — no password.

+ + {connected && } + {result != null &&

✓ Logged in as {result.slice(0, 16)}…

} + {error != null &&

{error}

} + {/* --- demo activity log (safe to delete) --- */} + {log.length > 0 && ( +
    + {log.map((m, i) =>
  1. {m}
  2. )} +
+ )} + {/* --- end demo activity log --- */} +
+ ) +} +` + +const HOOK = `// Wallet login: prove identity with the connected wallet, then POST the proof. +import { useCallback } from 'react' +import { useWallet } from './WalletContext.js' +import { createAuthProof } from './auth.js' +import { getServerIdentity } from './serverIdentity.js' +import { API_BASE_URL } from './config.js' + +// serverIdentityKey is optional: when omitted it's fetched from GET /api/identity. +export interface UseWalletLoginOptions { serverIdentityKey?: string, loginEndpoint?: string } + +export function useWalletLogin (opts: UseWalletLoginOptions = {}) { + const { wallet, identityKey } = useWallet() + const login = useCallback(async (): Promise<{ identityKey: string }> => { + if (wallet === null) throw new Error('connect a wallet first (initializeWallet / relay)') + const counterparty = opts.serverIdentityKey ?? await getServerIdentity() + const proof = await createAuthProof(wallet, { counterparty, action: 'login' }) + const res = await fetch(API_BASE_URL + (opts.loginEndpoint ?? '/api/login'), { + method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(proof) + }) + if (!res.ok) throw new Error('login failed: ' + String(res.status)) + return await res.json() + }, [wallet, opts.serverIdentityKey, opts.loginEndpoint]) + return { login, identityKey, connected: wallet !== null } +} +` + +const ROUTE = `// Express login route. Mount: app.post('/api/login', loginRoute(serverWallet)) +import type { Request, Response } from 'express' +import { verifyAuthProof } from './auth.js' + +const usedNonces = new Map() + +export function loginRoute (serverWallet: { verifySignature: (args: any) => Promise<{ valid: boolean }> }) { + return async (req: Request, res: Response): Promise => { + const result = await verifyAuthProof(serverWallet, req.body, { action: 'login' }, (nonce, expiresAt) => { + if (usedNonces.has(nonce)) return false + usedNonces.set(nonce, expiresAt.getTime()) + return true + }) + if (!result.valid) { res.status(401).json({ error: result.error ?? 'invalid proof' }); return } + res.json({ identityKey: result.identityKey }) + } +} +` + +function agentsSection (_ctx: CapabilityContext): string { + return `## wallet-login + +Passwordless login: the connected wallet signs a proof with \`action: 'login'\`, the server verifies it, and you get a trusted \`identityKey\` — no password, no shared secret. + +### How it works +- The client fetches the server's identity key (\`GET /api/identity\`) to use as the proof \`counterparty\`, signs a login proof with the wallet, and POSTs it to \`/api/login\`. +- The server verifies the signature with its \`serverWallet\` and consumes a single-use nonce (replay protection), then trusts the \`identityKey\` the proof was signed by. +- That verified \`identityKey\` is the whole BSV-specific step. What you do next — issue a session, create a user — is your app's call (see *Future integrations*). The demo page renders each step so you can watch the exchange. + +### How it's used +- \`WalletLogin.tsx\` (client page) — login UI at \`/login\`; resolves the counterparty via \`getServerIdentity()\` and shows a step-by-step activity log of the exchange. +- \`useWalletLogin.tsx\` (client hook) — \`const { login } = useWalletLogin()\` for a custom UI; pass \`{ serverIdentityKey }\` to pin a key instead of auto-fetching. +- \`loginRoute.ts\` (server) — \`app.post('/api/login', loginRoute(serverWallet))\`; verifies the proof and returns \`{ identityKey }\`. + +### Environment (in \`bsv/config.ts\`) +- Client: \`API_BASE_URL\` (default \`http://localhost:3000\`, override with \`VITE_API_URL\`). +- Server: \`SERVER_PRIVATE_KEY\` (the \`serverWallet\` key; random dev fallback), \`PORT\`, \`CLIENT_ORIGIN\` (CORS allow-origin). + +### Future integrations — turn login into a session +After \`/api/login\` verifies the proof you hold a trusted \`identityKey\`; mint a session from it however your app prefers. A minimal JWT example with \`jose\` (read a secret from env, like \`serverWallet\` does its key): +\`\`\`ts +import { SignJWT, jwtVerify } from 'jose' +const secret = new TextEncoder().encode(process.env.JWT_SECRET ?? 'dev-only-secret') +// in loginRoute, once the proof verifies: +const token = await new SignJWT({ sub: result.identityKey }).setProtectedHeader({ alg: 'HS256' }).setExpirationTime('7d').sign(secret) +res.cookie('session', token, { httpOnly: true, sameSite: 'lax' }) // or return it for a bearer header +// guard a route: const { payload } = await jwtVerify(token, secret) // payload.sub === identityKey +\`\`\` +- Swap the in-memory nonce store in \`loginRoute.ts\` for Redis/DB in production. +- Persist a user record keyed by \`identityKey\` on first login. +` +} + +export const walletLogin: Capability = { + id: 'wallet-login', + title: 'Wallet login (passwordless, BRC-103 proof)', + description: 'Prove identity with the connected wallet; server verifies the proof. Builds on wallet-connect.', + requires: ['wallet-connect'], + roles: ['client', 'server'], + files: () => ({ + client: [ + { path: 'WalletLogin.tsx', content: WALLET_LOGIN_PAGE }, + { path: 'useWalletLogin.tsx', content: HOOK } + ], + server: [{ path: 'loginRoute.ts', content: ROUTE }] + }), + baseEdits: ({ builder, ctx }: { builder: BaseBuilder, ctx: CapabilityContext }) => { + builder.app.routes.push({ path: '/login', component: 'WalletLogin', importPath: bsvImport(ctx, 'WalletLogin'), label: 'Wallet login' }) + builder.server.imports.push(`import { loginRoute } from '${bsvImport(ctx, 'loginRoute.js')}'`) + builder.server.routes.push("app.post('/api/login', loginRoute(serverWallet))") + }, + npmDependencies: () => ({ + client: { react: '>=18' }, + server: { express: '^5.0.0' } + }), + agentsSection +} diff --git a/packages/helpers/create-bsv-app/src/cli.ts b/packages/helpers/create-bsv-app/src/cli.ts new file mode 100644 index 000000000..356bd37b8 --- /dev/null +++ b/packages/helpers/create-bsv-app/src/cli.ts @@ -0,0 +1,95 @@ +import type { ProjectConfig, PackageManager } from './config/model.js' +import { readValidManifest, type ProjectManifest } from './config/project-manifest.js' +import { resolveConfigFromFile } from './config/file.js' +import { resolveDraft, seedDraft, type ConfigDraft } from './config/draft.js' +import type { RunCommand } from './scaffold/base-scaffolder.js' +import type { ConfigProvider } from './prompts.js' +import { applyConfig, type RunResult } from './pipeline.js' + +export type StartUi = (opts: { existing: ProjectManifest | null, targetDir: string, runCommand?: RunCommand }) => Promise + +export interface CliArgs { dir?: string, file?: string, yes: boolean, force: boolean, ui: boolean, draft: ConfigDraft } + +export function parseArgs (argv: string[]): CliArgs { + const args: CliArgs = { yes: false, force: false, ui: false, draft: {} } + const next = (i: number): [string | undefined, number] => [argv[i + 1], i + 1] + for (let i = 0; i < argv.length; i++) { + const a = argv[i] + if (a === '--dir') { + [args.dir, i] = next(i) + } else if (a === '--file') { + [args.file, i] = next(i) + } else if (a === '--yes') { + args.yes = true + } else if (a === '--force') { + args.force = true + } else if (a === '--ui') { + args.ui = true + } else if (a === '--glue') { + args.draft.glue = true + } else if (a === '--no-glue') { + args.draft.glue = false + } else if (a === '--mode') { + const [v, j] = next(i); i = j + args.draft.mode = v === 'add' ? 'add' : 'new' + } else if (a === '--name') { + const [v, j] = next(i); i = j + args.draft.name = v + } else if (a === '--frontend') { + const [v, j] = next(i); i = j + args.draft.frontend = v === 'react' ? 'react' : 'none' + } else if (a === '--backend') { + const [v, j] = next(i); i = j + args.draft.backend = v === 'express' ? 'express' : 'none' + } else if (a === '--variant') { + const [v, j] = next(i); i = j + args.draft.frontendVariant = v + } else if (a === '--bsv-dir') { + const [v, j] = next(i); i = j + args.draft.bsvDir = v + } else if (a === '--capabilities') { + const [v, j] = next(i); i = j + args.draft.capabilities = (v ?? '').split(',').filter(Boolean) + } else if (a === '--package-manager') { + const [v, j] = next(i); i = j + args.draft.packageManager = ['npm', 'pnpm', 'yarn', 'bun'].includes(v ?? '') ? v as PackageManager : undefined + } else if (a === '--network') { + const [v, j] = next(i); i = j + args.draft.network = v === 'main' ? 'main' : 'test' + } else if (args.dir === undefined && !a.startsWith('--')) { + args.dir = a + } + } + return args +} + +export async function run ( + argv: string[], + provider?: ConfigProvider, + deps?: { runCommand?: RunCommand, startUi?: StartUi } +): Promise { + const args = parseArgs(argv) + const targetDir = args.dir ?? '.' + + if (args.ui) { + const existing = readValidManifest(targetDir) + const startUi = deps?.startUi ?? (async (o: { existing: ProjectManifest | null, targetDir: string, runCommand?: RunCommand }) => { return await (await import('./ui/ui-server.js')).runUi(o) }) + return await startUi({ existing, targetDir, runCommand: deps?.runCommand }) + } + + let config: ProjectConfig + if (args.file !== undefined) { + // The file is the source of truth, but an explicit --mode flag overrides its "mode". + config = resolveConfigFromFile(args.file, { overrideMode: args.draft.mode }) + } else { + const existing = readValidManifest(targetDir) + if (args.yes) { + config = resolveDraft(seedDraft(existing, args.draft)) + } else { + if (provider === undefined) throw new Error('interactive run requires a config provider') + config = await provider({ existing, flags: args.draft }) + } + } + + return applyConfig(config, targetDir, { runCommand: deps?.runCommand, force: args.force }) +} diff --git a/packages/helpers/create-bsv-app/src/config/__tests__/draft-defaults.test.ts b/packages/helpers/create-bsv-app/src/config/__tests__/draft-defaults.test.ts new file mode 100644 index 000000000..47ceef14e --- /dev/null +++ b/packages/helpers/create-bsv-app/src/config/__tests__/draft-defaults.test.ts @@ -0,0 +1,30 @@ +import { jest, describe, expect, test, beforeAll } from '@jest/globals' +import type { ConfigDraft } from '../draft' +import type { ProjectManifest } from '../project-manifest' + +jest.mock('../../registry', () => ({ + listCapabilities: () => ([ + { id: 'wallet-connect', defaultSelected: true, roles: [], files: () => ({}), npmDependencies: () => ({}), agentsSection: () => '' }, + { id: 'extra', roles: [], files: () => ({}), npmDependencies: () => ({}), agentsSection: () => '' } + ]) +})) + +let seedDraft: (existing: ProjectManifest | null, flags: ConfigDraft) => ConfigDraft + +beforeAll(async () => { + const mod = await import('../draft') + seedDraft = mod.seedDraft +}) + +describe('seedDraft default-selected capabilities', () => { + test('NEW mode (no manifest) pre-selects defaultSelected ids', () => { + expect(seedDraft(null, {}).capabilities).toEqual(['wallet-connect']) + }) + test('NEW mode unions explicit flags with defaults', () => { + expect(seedDraft(null, { capabilities: ['extra'] }).capabilities?.sort()).toEqual(['extra', 'wallet-connect']) + }) + test('ADD mode does NOT auto-select defaults', () => { + const m = { version: 1 as const, name: 'x', network: 'test' as const, stack: {}, bsvDir: 'src/bsv', capabilities: [] } + expect(seedDraft(m, {}).capabilities).toEqual([]) + }) +}) diff --git a/packages/helpers/create-bsv-app/src/config/__tests__/draft.test.ts b/packages/helpers/create-bsv-app/src/config/__tests__/draft.test.ts new file mode 100644 index 000000000..2cb504950 --- /dev/null +++ b/packages/helpers/create-bsv-app/src/config/__tests__/draft.test.ts @@ -0,0 +1,47 @@ +// src/config/__tests__/draft.test.ts +import { describe, expect, test } from '@jest/globals' +import { draftToConfigInput, resolveDraft, seedDraft } from '../draft' +import type { ProjectManifest } from '../project-manifest' + +describe('draftToConfigInput', () => { + test('maps react frontend + express backend to a nested stack', () => { + const input = draftToConfigInput({ mode: 'new', name: 'demo', frontend: 'react', frontendVariant: 'react-ts', backend: 'express', capabilities: ['wallet-login'] }) as any + expect(input.stack).toEqual({ frontend: { framework: 'react', variant: 'react-ts' }, backend: { framework: 'express' } }) + }) + test("omits 'none' targets", () => { + const input = draftToConfigInput({ mode: 'new', name: 'demo', frontend: 'none', backend: 'express' }) as any + expect(input.stack).toEqual({ backend: { framework: 'express' } }) + }) +}) + +describe('resolveDraft', () => { + test('produces a validated ProjectConfig with resolveConfig defaults', () => { + const c = resolveDraft({ mode: 'new', name: 'demo', frontend: 'react' }) + expect(c.stack.frontend).toEqual({ framework: 'react', variant: 'react-ts' }) + expect(c.bsvDir).toBe('src/bsv') + expect(c.packageManager).toBe('npm') + }) + + test('a new project with no targets is rejected by resolveConfig', () => { + expect(() => resolveDraft({ mode: 'new', name: 'demo', frontend: 'none', backend: 'none' })).toThrow(/frontend or a backend/i) + }) +}) + +describe('seedDraft', () => { + test('no manifest, no mode flag → new', () => { + expect(seedDraft(null, {}).mode).toBe('new') + }) + test('existing manifest, no mode flag → add, locked from manifest', () => { + const m: ProjectManifest = { version: 1, name: 'demo', network: 'test', stack: { backend: { framework: 'express' } }, bsvDir: 'src/bsv', capabilities: ['wallet-login'] } + const d = seedDraft(m, {}) + expect(d.mode).toBe('add') + expect(d.backend).toBe('express') + expect(d.frontend).toBe('none') + expect(d.name).toBe('demo') + expect(d.capabilities).toEqual(['wallet-login']) + }) + test('mode flag overrides the manifest default', () => { + const m: ProjectManifest = { version: 1, name: 'x', network: 'test', stack: {}, bsvDir: 'src/bsv', capabilities: [] } + expect(seedDraft(m, { mode: 'new' }).mode).toBe('new') + }) +}) diff --git a/packages/helpers/create-bsv-app/src/config/__tests__/file.test.ts b/packages/helpers/create-bsv-app/src/config/__tests__/file.test.ts new file mode 100644 index 000000000..f36a6d4fb --- /dev/null +++ b/packages/helpers/create-bsv-app/src/config/__tests__/file.test.ts @@ -0,0 +1,42 @@ +// src/config/__tests__/file.test.ts +import { describe, expect, test, beforeEach, afterEach } from '@jest/globals' +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { resolveConfigFromFile } from '../file' +import { ConfigError } from '../validate' + +let dir: string +beforeEach(() => { dir = mkdtempSync(join(tmpdir(), 'cba-file-')) }) +afterEach(() => { rmSync(dir, { recursive: true, force: true }) }) + +function writeFile (name: string, content: string): string { + const p = join(dir, name) + writeFileSync(p, content) + return p +} + +describe('resolveConfigFromFile', () => { + test('loads and resolves a valid config file', () => { + const p = writeFile('config.json', JSON.stringify({ name: 'demo', stack: { frontend: { framework: 'react' } }, capabilities: ['wallet-login'] })) + const c = resolveConfigFromFile(p) + expect(c.name).toBe('demo') + expect(c.stack.frontend?.framework).toBe('react') + // new-mode floor appends defaultSelected baseline (wallet-connect) after the explicit entry + expect(c.capabilities).toEqual(['wallet-login', 'wallet-connect']) + }) + + test('throws ConfigError on missing file', () => { + expect(() => resolveConfigFromFile(join(dir, 'nope.json'))).toThrow(ConfigError) + }) + + test('throws ConfigError on malformed JSON', () => { + const p = writeFile('bad.json', '{ not json') + expect(() => resolveConfigFromFile(p)).toThrow(/invalid JSON/i) + }) + + test('propagates ConfigError from resolveConfig (unknown capability)', () => { + const p = writeFile('bad-cap.json', JSON.stringify({ name: 'x', stack: { backend: { framework: 'express' } }, capabilities: ['nope'] })) + expect(() => resolveConfigFromFile(p)).toThrow(/unknown capability/i) + }) +}) diff --git a/packages/helpers/create-bsv-app/src/config/__tests__/model.test.ts b/packages/helpers/create-bsv-app/src/config/__tests__/model.test.ts new file mode 100644 index 000000000..6bb94df9d --- /dev/null +++ b/packages/helpers/create-bsv-app/src/config/__tests__/model.test.ts @@ -0,0 +1,24 @@ +// src/config/__tests__/model.test.ts +import { describe, expect, test } from '@jest/globals' +import { isMonorepo, layoutOf } from '../model' +import type { Stack } from '../model' + +describe('layout helpers', () => { + const fe: Stack = { frontend: { framework: 'react', variant: 'react-ts' } } + const be: Stack = { backend: { framework: 'express' } } + const both: Stack = { ...fe, ...be } + + test('isMonorepo is true only when both targets present', () => { + expect(isMonorepo(both)).toBe(true) + expect(isMonorepo(fe)).toBe(false) + expect(isMonorepo(be)).toBe(false) + expect(isMonorepo({})).toBe(false) + }) + + test('layoutOf classifies each shape', () => { + expect(layoutOf(both)).toBe('monorepo') + expect(layoutOf(fe)).toBe('frontend-only') + expect(layoutOf(be)).toBe('backend-only') + expect(layoutOf({})).toBe('none') + }) +}) diff --git a/packages/helpers/create-bsv-app/src/config/__tests__/project-manifest.test.ts b/packages/helpers/create-bsv-app/src/config/__tests__/project-manifest.test.ts new file mode 100644 index 000000000..12ce4175b --- /dev/null +++ b/packages/helpers/create-bsv-app/src/config/__tests__/project-manifest.test.ts @@ -0,0 +1,97 @@ +// src/config/__tests__/project-manifest.test.ts +import { describe, expect, test, beforeEach, afterEach } from '@jest/globals' +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { manifestFromConfig, readProjectManifest, writeProjectManifest, mergeCapabilityIds, remainingCapabilityIds, readValidManifest } from '../project-manifest' +import type { ProjectConfig } from '../model' + +let dir: string +beforeEach(() => { dir = mkdtempSync(join(tmpdir(), 'cba-m-')) }) +afterEach(() => { rmSync(dir, { recursive: true, force: true }) }) + +const config: ProjectConfig = { + mode: 'new', + name: 'demo', + dir: '.', + stack: { frontend: { framework: 'react', variant: 'react-ts' }, backend: { framework: 'express' } }, + bsvDir: 'src/bsv', + capabilities: ['wallet-login'], + glue: false, + packageManager: 'npm', + network: 'test' +} + +describe('project manifest', () => { + test('manifestFromConfig keeps stack/name/network/bsvDir/capabilities and sets version 1', () => { + expect(manifestFromConfig(config)).toEqual({ version: 1, name: 'demo', network: 'test', stack: config.stack, bsvDir: 'src/bsv', capabilities: ['wallet-login'] }) + }) + + test('round-trips through disk', () => { + const m = manifestFromConfig(config) + writeProjectManifest(dir, m) + expect(readProjectManifest(dir)).toEqual(m) + }) + + test('readProjectManifest returns null when absent', () => { + expect(readProjectManifest(dir)).toBeNull() + }) +}) + +describe('manifest ops', () => { + test('mergeCapabilityIds unions without duplicates, order-stable', () => { + expect(mergeCapabilityIds(['a'], ['a', 'b'])).toEqual(['a', 'b']) + }) + + test('mergeCapabilityIds with empty existing returns added', () => { + expect(mergeCapabilityIds([], ['wallet-login'])).toEqual(['wallet-login']) + }) + + test('remainingCapabilityIds excludes capabilities already in manifest', () => { + const m = manifestFromConfig(config) + expect(remainingCapabilityIds(m, ['wallet-login', 'x'])).toEqual(['x']) + }) + + test('remainingCapabilityIds returns all when none installed', () => { + const empty = manifestFromConfig({ ...config, capabilities: [] }) + expect(remainingCapabilityIds(empty, ['wallet-login', 'x'])).toEqual(['wallet-login', 'x']) + }) + + test('readValidManifest returns null when file absent', () => { + expect(readValidManifest(dir)).toBeNull() + }) + + test('readValidManifest returns manifest for a valid file', () => { + const m = manifestFromConfig(config) + writeProjectManifest(dir, m) + expect(readValidManifest(dir)).toEqual(m) + }) + + test('readValidManifest throws on malformed file (capabilities not array)', () => { + writeFileSync(join(dir, 'bsv-scaffold.json'), JSON.stringify({ + version: 1, name: 'test', network: 'test', stack: {}, bsvDir: 'src/bsv', capabilities: 'oops' + }) + '\n') + expect(() => readValidManifest(dir)).toThrow('malformed bsv-scaffold.json') + }) + + test('readValidManifest throws on wrong version', () => { + writeFileSync(join(dir, 'bsv-scaffold.json'), JSON.stringify({ + version: 2, name: 'test', network: 'test', stack: {}, bsvDir: 'src/bsv', capabilities: [] + }) + '\n') + expect(() => readValidManifest(dir)).toThrow('malformed bsv-scaffold.json') + }) + + test('readValidManifest throws on path-traversal bsvDir', () => { + writeFileSync(join(dir, 'bsv-scaffold.json'), JSON.stringify({ + version: 1, name: 'test', network: 'test', stack: {}, bsvDir: '../escape', capabilities: [] + }) + '\n') + expect(() => readValidManifest(dir)).toThrow('malformed bsv-scaffold.json') + }) + + test('readValidManifest throws on unsupported frontend framework', () => { + writeFileSync(join(dir, 'bsv-scaffold.json'), JSON.stringify({ + version: 1, name: 'test', network: 'test', stack: { frontend: { framework: 'svelte' } }, bsvDir: 'src/bsv', capabilities: [] + }) + '\n') + expect(() => readValidManifest(dir)).toThrow('malformed bsv-scaffold.json') + }) +}) diff --git a/packages/helpers/create-bsv-app/src/config/__tests__/schema.test.ts b/packages/helpers/create-bsv-app/src/config/__tests__/schema.test.ts new file mode 100644 index 000000000..d282a3860 --- /dev/null +++ b/packages/helpers/create-bsv-app/src/config/__tests__/schema.test.ts @@ -0,0 +1,86 @@ +// src/config/__tests__/schema.test.ts +import { describe, expect, test } from '@jest/globals' +import { configSchema, isFieldVisible, visibleFields, evaluateWhen } from '../schema' +import type { ConfigField } from '../schema' + +function field (key: string): ConfigField { + for (const s of configSchema) { + const f = s.fields.find(x => x.key === key) + if (f !== undefined) return f + } + throw new Error(`field not found: ${key}`) +} + +describe('evaluateWhen', () => { + test('undefined when is always visible', () => { + expect(evaluateWhen(undefined, {})).toBe(true) + }) + test('all entries must match (AND)', () => { + expect(evaluateWhen({ mode: 'new', frontend: 'react' }, { mode: 'new', frontend: 'react' })).toBe(true) + expect(evaluateWhen({ mode: 'new', frontend: 'react' }, { mode: 'new', frontend: 'none' })).toBe(false) + expect(evaluateWhen({ mode: 'new' }, { mode: 'add' })).toBe(false) + }) + test('missing draft key fails the condition', () => { + expect(evaluateWhen({ mode: 'new' }, {})).toBe(false) + }) +}) + +describe('config schema', () => { + test('has the expected sections', () => { + expect(configSchema.map(s => s.id)).toEqual(['mode', 'project', 'stack', 'bsv', 'tooling']) + }) + + test('mode is the first section', () => { + expect(configSchema[0].id).toBe('mode') + expect(configSchema[0].fields.map(f => f.key)).toContain('mode') + }) + + test('when conditions are declarative objects', () => { + expect(field('frontend').when).toEqual({ mode: 'new' }) + expect(field('frontendVariant').when).toEqual({ mode: 'new', frontend: 'react' }) + expect(field('capabilities').when).toBeUndefined() + }) + + test('new-only fields are hidden in add mode', () => { + expect(isFieldVisible(field('frontend'), { mode: 'add' })).toBe(false) + expect(isFieldVisible(field('frontend'), { mode: 'new' })).toBe(true) + expect(isFieldVisible(field('capabilities'), { mode: 'add' })).toBe(true) + }) + + test('frontendVariant is hidden unless mode is new and frontend is react', () => { + const f = field('frontendVariant') + expect(isFieldVisible(f, { mode: 'new', frontend: 'none' })).toBe(false) + expect(isFieldVisible(f, { mode: 'new', frontend: 'react' })).toBe(true) + expect(isFieldVisible(f, { mode: 'add', frontend: 'react' })).toBe(false) + }) + + test('capabilities options come from the registry (includes wallet-login)', () => { + expect(field('capabilities').options?.map(o => o.value)).toContain('wallet-login') + }) + + test('visibleFields filters the stack section by the draft', () => { + const stack = configSchema.find(s => s.id === 'stack') + if (stack === undefined) throw new Error('no stack section') + expect(visibleFields(stack, { mode: 'new', frontend: 'none' }).map(f => f.key)).not.toContain('frontendVariant') + expect(visibleFields(stack, { mode: 'new', frontend: 'react' }).map(f => f.key)).toContain('frontendVariant') + }) +}) + +describe('schema ui/desc hints', () => { + test('sections carry a desc string', () => { + for (const s of configSchema) expect(typeof s.desc).toBe('string') + }) + test('mode/frontend/backend/network fields are segmented; type stays select', () => { + const f = (k: string): ConfigField => configSchema.flatMap(s => s.fields).find(x => x.key === k) ?? (() => { throw new Error(k) })() + for (const k of ['mode', 'frontend', 'backend', 'network']) { + expect(f(k).ui).toBe('segmented') + expect(f(k).type).toBe('select') + } + }) + test('hints do not affect visibility logic', () => { + const frontend = configSchema.flatMap(s => s.fields).find(x => x.key === 'frontend') + if (frontend === undefined) throw new Error('no frontend field') + expect(isFieldVisible(frontend, { mode: 'new' })).toBe(true) + expect(isFieldVisible(frontend, { mode: 'add' })).toBe(false) + }) +}) diff --git a/packages/helpers/create-bsv-app/src/config/__tests__/validate.test.ts b/packages/helpers/create-bsv-app/src/config/__tests__/validate.test.ts new file mode 100644 index 000000000..e6450fbb7 --- /dev/null +++ b/packages/helpers/create-bsv-app/src/config/__tests__/validate.test.ts @@ -0,0 +1,140 @@ +// src/config/__tests__/validate.test.ts +import { describe, expect, test } from '@jest/globals' +import { resolveConfig, ConfigError, formatConfigError } from '../validate' + +const minimal = { name: 'demo', stack: { frontend: { framework: 'react' } } } + +describe('resolveConfig', () => { + test('applies defaults to a minimal valid config', () => { + const c = resolveConfig(minimal) + expect(c).toEqual({ + mode: 'new', + name: 'demo', + dir: '.', + stack: { frontend: { framework: 'react', variant: 'react-ts' } }, + bsvDir: 'src/bsv', + capabilities: ['wallet-connect'], + glue: true, + packageManager: 'npm', + network: 'test' + }) + }) + + test('throws when name missing', () => { + expect(() => resolveConfig({ stack: { backend: { framework: 'express' } } })).toThrow(ConfigError) + }) + + test('throws when new project has no targets', () => { + expect(() => resolveConfig({ name: 'x' })).toThrow(/frontend or a backend/i) + }) + + test('throws on an invalid frontend framework', () => { + expect(() => resolveConfig({ name: 'x', stack: { frontend: { framework: 'svelte' } } })).toThrow(ConfigError) + }) + + test('throws on an unknown capability', () => { + expect(() => resolveConfig({ name: 'x', stack: { backend: { framework: 'express' } }, capabilities: ['nope'] })) + .toThrow(/unknown capability: nope/i) + }) + + test('dedupes capabilities and accepts known ones (new mode floor adds wallet-connect)', () => { + const c = resolveConfig({ name: 'x', stack: { backend: { framework: 'express' } }, capabilities: ['wallet-login', 'wallet-login'] }) + // new-mode floor appends wallet-connect after dedup; wallet-login itself is deduped to one entry + expect(c.capabilities).toEqual(['wallet-login', 'wallet-connect']) + }) + + test('rejects an unsafe bsvDir', () => { + expect(() => resolveConfig({ name: 'x', stack: { backend: { framework: 'express' } }, bsvDir: '../escape' })).toThrow(ConfigError) + }) + + test('normalizes packageManager and network with defaults', () => { + const c = resolveConfig({ name: 'x', stack: { backend: { framework: 'express' } }, packageManager: 'maven', network: 'main' }) + expect(c.packageManager).toBe('npm') + expect(c.network).toBe('main') + }) + + test('formatConfigError prefixes ConfigError messages', () => { + expect(formatConfigError(new ConfigError('bad'))).toBe('Invalid config: bad') + }) +}) + +describe('resolveConfig - more branches', () => { + test('invalid BACKEND framework throws ConfigError', () => { + expect(() => resolveConfig({ name: 'x', stack: { backend: { framework: 'django' } } })).toThrow(ConfigError) + }) + + test('absolute bsvDir with leading slash throws ConfigError', () => { + expect(() => resolveConfig({ name: 'x', stack: { backend: { framework: 'express' } }, bsvDir: '/etc' })).toThrow(ConfigError) + }) + + test('absolute bsvDir with drive letter throws ConfigError', () => { + expect(() => resolveConfig({ name: 'x', stack: { backend: { framework: 'express' } }, bsvDir: 'C:\\windows' })).toThrow(ConfigError) + }) + + test('non-array capabilities throws', () => { + expect(() => resolveConfig({ name: 'x', stack: { backend: { framework: 'express' } }, capabilities: 'wallet-login' })).toThrow(/capabilities must be an array/i) + }) + + test('mode add is accepted without a stack', () => { + const c = resolveConfig({ mode: 'add', name: 'x' }) + expect(c.mode).toBe('add') + }) + + test('overrideMode wins over the config\'s own mode field', () => { + // file says add, caller forces add→new: floor applies, so wallet-connect is added + const c = resolveConfig({ mode: 'add', name: 'x', stack: { frontend: { framework: 'react', variant: 'react-ts' } }, capabilities: [] }, { overrideMode: 'new' }) + expect(c.mode).toBe('new') + expect(c.capabilities).toContain('wallet-connect') // new-mode floor ran for the effective mode + }) + + test('overrideMode new still enforces new-mode validation (needs a target)', () => { + expect(() => resolveConfig({ mode: 'add', name: 'x' }, { overrideMode: 'new' })).toThrow(/frontend or a backend/i) + }) + + test('overrideMode add skips the floor even when the file said new', () => { + const c = resolveConfig({ mode: 'new', name: 'x', stack: { backend: { framework: 'express' } }, capabilities: ['wallet-login'] }, { overrideMode: 'add' }) + expect(c.mode).toBe('add') + expect(c.capabilities).toEqual(['wallet-login']) // no floor in add mode + }) + + test('formatConfigError returns message for plain Error', () => { + expect(formatConfigError(new Error('boom'))).toBe('boom') + }) + + test('formatConfigError returns string for non-Error', () => { + expect(formatConfigError('plain')).toBe('plain') + }) +}) + +const base = { mode: 'new', name: 'demo', stack: { frontend: { framework: 'react', variant: 'react-ts' } } } + +describe('resolveConfig glue default', () => { + test('glue defaults to true when unspecified', () => { + expect(resolveConfig({ ...base }).glue).toBe(true) + }) + test('glue is false only when explicitly false', () => { + expect(resolveConfig({ ...base, glue: false }).glue).toBe(false) + }) + test('glue true stays true', () => { + expect(resolveConfig({ ...base, glue: true }).glue).toBe(true) + }) +}) + +describe('resolveConfig new-mode capability floor', () => { + test('new mode with no capabilities still includes the defaultSelected baseline', () => { + const c = resolveConfig({ mode: 'new', name: 'demo', stack: { frontend: { framework: 'react', variant: 'react-ts' } } }) + expect(c.capabilities).toContain('wallet-connect') + }) + test('new mode with explicitly empty capabilities still gets the baseline', () => { + const c = resolveConfig({ mode: 'new', name: 'demo', stack: { backend: { framework: 'express' } }, capabilities: [] }) + expect(c.capabilities).toContain('wallet-connect') + }) + test('add mode does NOT apply the floor (no auto-add)', () => { + const c = resolveConfig({ mode: 'add', name: 'demo', stack: { frontend: { framework: 'react', variant: 'react-ts' } }, capabilities: [] }) + expect(c.capabilities).toEqual([]) + }) + test('new mode does not duplicate an already-listed baseline', () => { + const c = resolveConfig({ mode: 'new', name: 'demo', stack: { frontend: { framework: 'react', variant: 'react-ts' } }, capabilities: ['wallet-connect'] }) + expect(c.capabilities.filter(id => id === 'wallet-connect')).toHaveLength(1) + }) +}) diff --git a/packages/helpers/create-bsv-app/src/config/draft.ts b/packages/helpers/create-bsv-app/src/config/draft.ts new file mode 100644 index 000000000..c2ee93789 --- /dev/null +++ b/packages/helpers/create-bsv-app/src/config/draft.ts @@ -0,0 +1,60 @@ +// src/config/draft.ts +import type { ProjectConfig, PackageManager, Network } from './model.js' +import type { ProjectManifest } from './project-manifest.js' +import { mergeCapabilityIds } from './project-manifest.js' +import { resolveConfig } from './validate.js' +import { listCapabilities } from '../registry.js' + +export interface ConfigDraft { + mode?: 'new' | 'add' + name?: string + frontend?: 'react' | 'none' + frontendVariant?: string + backend?: 'express' | 'none' + bsvDir?: string + capabilities?: string[] + glue?: boolean + packageManager?: PackageManager + network?: Network +} + +export function draftToConfigInput (d: ConfigDraft): Record { + const stack: Record = {} + if (d.frontend === 'react') stack.frontend = { framework: 'react', variant: d.frontendVariant ?? 'react-ts' } + if (d.backend === 'express') stack.backend = { framework: 'express' } + return { + mode: d.mode, + name: d.name, + stack, + bsvDir: d.bsvDir, + capabilities: d.capabilities, + glue: d.glue, + packageManager: d.packageManager, + network: d.network + } +} + +export function resolveDraft (d: ConfigDraft): ProjectConfig { + return resolveConfig(draftToConfigInput(d)) +} + +export function seedDraft (existing: ProjectManifest | null, flags: ConfigDraft): ConfigDraft { + const mode = flags.mode ?? (existing != null ? 'add' : 'new') + if (mode === 'add' && existing != null) { + return { + mode: 'add', + name: existing.name, + frontend: existing.stack.frontend != null ? 'react' : 'none', + frontendVariant: existing.stack.frontend?.variant, + backend: existing.stack.backend != null ? 'express' : 'none', + bsvDir: existing.bsvDir, + network: existing.network, + glue: flags.glue, + packageManager: flags.packageManager, + capabilities: mergeCapabilityIds(existing.capabilities, flags.capabilities ?? []) + } + } + const defaults = listCapabilities().filter(c => c.defaultSelected === true).map(c => c.id) + const capabilities = mergeCapabilityIds(defaults, flags.capabilities ?? []) + return { ...flags, mode: 'new', capabilities } +} diff --git a/packages/helpers/create-bsv-app/src/config/file.ts b/packages/helpers/create-bsv-app/src/config/file.ts new file mode 100644 index 000000000..6798e731d --- /dev/null +++ b/packages/helpers/create-bsv-app/src/config/file.ts @@ -0,0 +1,21 @@ +// src/config/file.ts +import { readFileSync, existsSync } from 'node:fs' +import type { ProjectConfig, Mode } from './model.js' +import { resolveConfig, ConfigError } from './validate.js' + +export function resolveConfigFromFile (path: string, opts: { overrideMode?: Mode } = {}): ProjectConfig { + if (!existsSync(path)) throw new ConfigError(`config file not found: ${path}`) + let text: string + try { + text = readFileSync(path, 'utf8') + } catch { + throw new ConfigError(`cannot read config file: ${path}`) + } + let parsed: unknown + try { + parsed = JSON.parse(text) + } catch { + throw new ConfigError(`invalid JSON in ${path}`) + } + return resolveConfig(parsed, opts) +} diff --git a/packages/helpers/create-bsv-app/src/config/model.ts b/packages/helpers/create-bsv-app/src/config/model.ts new file mode 100644 index 000000000..61a56d71f --- /dev/null +++ b/packages/helpers/create-bsv-app/src/config/model.ts @@ -0,0 +1,36 @@ +// src/config/model.ts +export type FrontendFramework = 'react' +export type BackendFramework = 'express' +export type PackageManager = 'npm' | 'pnpm' | 'yarn' | 'bun' +export type Network = 'main' | 'test' +export type Mode = 'new' | 'add' +export type Layout = 'frontend-only' | 'backend-only' | 'monorepo' | 'none' + +export interface FrontendTarget { framework: FrontendFramework, variant: string } +export interface BackendTarget { framework: BackendFramework } +export interface Stack { frontend?: FrontendTarget, backend?: BackendTarget } + +export interface ProjectConfig { + mode: Mode + name: string + dir: string + stack: Stack + bsvDir: string + capabilities: string[] + glue: boolean + packageManager: PackageManager + network: Network +} + +export function isMonorepo (stack: Stack): boolean { + return stack.frontend != null && stack.backend != null +} + +export function layoutOf (stack: Stack): Layout { + const fe = stack.frontend != null + const be = stack.backend != null + if (fe && be) return 'monorepo' + if (fe) return 'frontend-only' + if (be) return 'backend-only' + return 'none' +} diff --git a/packages/helpers/create-bsv-app/src/config/project-manifest.ts b/packages/helpers/create-bsv-app/src/config/project-manifest.ts new file mode 100644 index 000000000..343c6a182 --- /dev/null +++ b/packages/helpers/create-bsv-app/src/config/project-manifest.ts @@ -0,0 +1,62 @@ +// src/config/project-manifest.ts +import { readFileSync, writeFileSync, existsSync } from 'node:fs' +import { join } from 'node:path' +import type { Network, Stack, ProjectConfig } from './model.js' +import { validBsvDir } from './validate.js' + +export const MANIFEST_FILE = 'bsv-scaffold.json' + +export interface ProjectManifest { + version: 1 + name: string + network: Network + stack: Stack + bsvDir: string + capabilities: string[] +} + +export function manifestFromConfig (config: ProjectConfig): ProjectManifest { + return { + version: 1, + name: config.name, + network: config.network, + stack: config.stack, + bsvDir: config.bsvDir, + capabilities: [...config.capabilities] + } +} + +export function readProjectManifest (dir: string): ProjectManifest | null { + const p = join(dir, MANIFEST_FILE) + if (!existsSync(p)) return null + return JSON.parse(readFileSync(p, 'utf8')) as ProjectManifest +} + +export function writeProjectManifest (dir: string, manifest: ProjectManifest): void { + writeFileSync(join(dir, MANIFEST_FILE), JSON.stringify(manifest, null, 2) + '\n') +} + +export function mergeCapabilityIds (existing: string[], added: string[]): string[] { + const out = [...existing] + for (const id of added) if (!out.includes(id)) out.push(id) + return out +} + +export function remainingCapabilityIds (manifest: ProjectManifest, knownIds: string[]): string[] { + return knownIds.filter(id => !manifest.capabilities.includes(id)) +} + +export function readValidManifest (dir: string): ProjectManifest | null { + const m = readProjectManifest(dir) + if (m === null) return null + const stackOk = m.stack !== null && typeof m.stack === 'object' && + (m.stack.frontend == null || m.stack.frontend.framework === 'react') && + (m.stack.backend == null || m.stack.backend.framework === 'express') + const ok = m.version === 1 && typeof m.name === 'string' && + (m.network === 'main' || m.network === 'test') && + stackOk && + typeof m.bsvDir === 'string' && validBsvDir(m.bsvDir) && + Array.isArray(m.capabilities) && m.capabilities.every(c => typeof c === 'string') + if (!ok) throw new Error('malformed bsv-scaffold.json') + return m +} diff --git a/packages/helpers/create-bsv-app/src/config/schema.ts b/packages/helpers/create-bsv-app/src/config/schema.ts new file mode 100644 index 000000000..87fa73f9e --- /dev/null +++ b/packages/helpers/create-bsv-app/src/config/schema.ts @@ -0,0 +1,83 @@ +// src/config/schema.ts +import { listCapabilities } from '../registry.js' + +export type FieldType = 'text' | 'select' | 'multiselect' | 'toggle' +export interface FieldOption { value: string, label: string, hint?: string } +/** Object map of field → required value; all entries are AND'd. */ +export type When = Record +export interface ConfigField { + key: string + label: string + type: FieldType + ui?: 'segmented' + options?: FieldOption[] + default?: string | boolean + when?: When +} +export interface ConfigSection { id: string, title: string, desc?: string, fields: ConfigField[] } +export type ConfigSchema = ConfigSection[] + +function capabilityOptions (): FieldOption[] { + return listCapabilities().map(c => ({ value: c.id, label: c.title, hint: c.description })) +} + +export const configSchema: ConfigSchema = [ + { + id: 'mode', + title: 'Mode', + desc: 'Create a new project or add BSV helpers to an existing one.', + fields: [ + { key: 'mode', label: 'Create a new project or add to an existing one?', type: 'select', ui: 'segmented', options: [{ value: 'new', label: 'New project' }, { value: 'add', label: 'Add to existing' }] } + ] + }, + { + id: 'project', + title: 'Project', + desc: 'Name your project.', + fields: [ + { key: 'name', label: 'Project name', type: 'text', when: { mode: 'new' } } + ] + }, + { + id: 'stack', + title: 'Stack', + desc: 'Frameworks scaffolded alongside your BSV helpers.', + fields: [ + { key: 'frontend', label: 'Frontend', type: 'select', ui: 'segmented', default: 'none', options: [{ value: 'none', label: 'None' }, { value: 'react', label: 'React (Vite)' }], when: { mode: 'new' } }, + { key: 'frontendVariant', label: 'React variant', type: 'select', default: 'react-ts', options: [{ value: 'react-ts', label: 'React + TypeScript' }], when: { mode: 'new', frontend: 'react' } }, + { key: 'backend', label: 'Backend', type: 'select', ui: 'segmented', default: 'none', options: [{ value: 'none', label: 'None' }, { value: 'express', label: 'Express (TypeScript)' }], when: { mode: 'new' } } + ] + }, + { + id: 'bsv', + title: 'BSV', + desc: 'Capabilities and integration helpers.', + fields: [ + { key: 'bsvDir', label: 'BSV helpers directory', type: 'text', default: 'src/bsv', when: { mode: 'new' } }, + { key: 'capabilities', label: 'Capabilities', type: 'multiselect', options: capabilityOptions() }, + { key: 'glue', label: 'Auto-wire wallet providers into the app entry (main.tsx)', type: 'toggle', default: true, when: { mode: 'new' } } + ] + }, + { + id: 'tooling', + title: 'Tooling', + desc: 'Package manager and target network.', + fields: [ + { key: 'packageManager', label: 'Package manager', type: 'select', default: 'npm', options: [{ value: 'npm', label: 'npm' }, { value: 'pnpm', label: 'pnpm' }, { value: 'yarn', label: 'yarn' }, { value: 'bun', label: 'bun' }], when: { mode: 'new' } }, + { key: 'network', label: 'Network', type: 'select', ui: 'segmented', default: 'test', options: [{ value: 'test', label: 'Testnet' }, { value: 'main', label: 'Mainnet' }], when: { mode: 'new' } } + ] + } +] + +export function evaluateWhen (when: When | undefined, draft: Record): boolean { + if (when === undefined) return true + return Object.keys(when).every(k => draft[k] === when[k]) +} + +export function isFieldVisible (field: ConfigField, draft: Record): boolean { + return evaluateWhen(field.when, draft) +} + +export function visibleFields (section: ConfigSection, draft: Record): ConfigField[] { + return section.fields.filter(f => isFieldVisible(f, draft)) +} diff --git a/packages/helpers/create-bsv-app/src/config/validate.ts b/packages/helpers/create-bsv-app/src/config/validate.ts new file mode 100644 index 000000000..0ca64d91c --- /dev/null +++ b/packages/helpers/create-bsv-app/src/config/validate.ts @@ -0,0 +1,98 @@ +// src/config/validate.ts +import type { ProjectConfig, PackageManager, Network, Mode, Stack } from './model.js' +import { getCapability, listCapabilities } from '../registry.js' + +export class ConfigError extends Error { + constructor (message: string) { + super(message) + this.name = 'ConfigError' + } +} + +const PACKAGE_MANAGERS: PackageManager[] = ['npm', 'pnpm', 'yarn', 'bun'] + +function asObject (input: unknown, label: string): Record { + if (input === null || typeof input !== 'object' || Array.isArray(input)) { + throw new ConfigError(`${label} must be an object`) + } + return input as Record +} + +function resolveStack (raw: unknown): Stack { + const stack: Stack = {} + if (raw === undefined) return stack + const s = asObject(raw, 'stack') + if (s.frontend !== undefined) { + const fe = asObject(s.frontend, 'stack.frontend') + if (fe.framework !== 'react') throw new ConfigError('stack.frontend.framework must be "react"') + const variant = typeof fe.variant === 'string' && fe.variant.length > 0 ? fe.variant : 'react-ts' + stack.frontend = { framework: 'react', variant } + } + if (s.backend !== undefined) { + const be = asObject(s.backend, 'stack.backend') + if (be.framework !== 'express') throw new ConfigError('stack.backend.framework must be "express"') + stack.backend = { framework: 'express' } + } + return stack +} + +export function validBsvDir (dir: string): boolean { + if (dir.length === 0) return false + if (dir.startsWith('/') || /^[A-Za-z]:/.test(dir)) return false + return !dir.split(/[/\\]/).includes('..') +} + +export function resolveConfig (input: unknown, opts: { overrideMode?: Mode } = {}): ProjectConfig { + const raw = asObject(input, 'config') + + // An explicit caller override (e.g. the --mode flag on the --file door) wins over the + // config's own "mode" field. Resolved here so the new-mode floor + validation below + // run against the effective mode. + const mode: Mode = opts.overrideMode ?? (raw.mode === 'add' ? 'add' : 'new') + + const name = typeof raw.name === 'string' ? raw.name.trim() : '' + if (name.length === 0) throw new ConfigError('name is required') + + const dir = typeof raw.dir === 'string' && raw.dir.length > 0 ? raw.dir : '.' + + const stack = resolveStack(raw.stack) + if (mode === 'new' && stack.frontend === undefined && stack.backend === undefined) { + throw new ConfigError('a new project needs at least a frontend or a backend') + } + + const bsvDir = typeof raw.bsvDir === 'string' && raw.bsvDir.length > 0 ? raw.bsvDir : 'src/bsv' + if (!validBsvDir(bsvDir)) throw new ConfigError(`invalid bsvDir: ${bsvDir}`) + + const capsRaw = raw.capabilities === undefined ? [] : raw.capabilities + if (!Array.isArray(capsRaw)) throw new ConfigError('capabilities must be an array') + const capabilities: string[] = [] + for (const c of capsRaw) { + if (typeof c !== 'string') throw new ConfigError('capabilities must be strings') + if (getCapability(c) === undefined) throw new ConfigError(`unknown capability: ${c}`) + if (!capabilities.includes(c)) capabilities.push(c) + } + + // new-mode floor: a new project always gets at least the defaultSelected baseline (e.g. wallet-connect), + // even when the config names zero capabilities. add mode has no floor. + if (mode === 'new') { + for (const c of listCapabilities()) { + if (c.defaultSelected === true && !capabilities.includes(c.id)) capabilities.push(c.id) + } + } + + const glue = raw.glue !== false + + const packageManager: PackageManager = PACKAGE_MANAGERS.includes(raw.packageManager as PackageManager) + ? raw.packageManager as PackageManager + : 'npm' + + const network: Network = raw.network === 'main' ? 'main' : 'test' + + return { mode, name, dir, stack, bsvDir, capabilities, glue, packageManager, network } +} + +export function formatConfigError (err: unknown): string { + if (err instanceof ConfigError) return `Invalid config: ${err.message}` + if (err instanceof Error) return err.message + return String(err) +} diff --git a/packages/helpers/create-bsv-app/src/engine.ts b/packages/helpers/create-bsv-app/src/engine.ts new file mode 100644 index 000000000..c6c89a7ea --- /dev/null +++ b/packages/helpers/create-bsv-app/src/engine.ts @@ -0,0 +1,86 @@ +// src/engine.ts +import { mkdirSync, writeFileSync, existsSync } from 'node:fs' +import { dirname, join } from 'node:path' +import type { FileSpec, Capability, CapabilityContext, Role } from './types.js' +import type { ProjectConfig, Layout } from './config/model.js' +import { layoutOf } from './config/model.js' + +export type TargetKey = 'root' | 'client' | 'server' +export interface PlacementResult { + utilFiles: FileSpec[] + glueFiles: FileSpec[] + deps: Record> +} + +const ROLES: Role[] = ['shared', 'client', 'server'] +const targetRoot = (t: TargetKey): string => (t === 'root' ? '' : t) + +function roleTargetsFor (layout: Layout): Record { + switch (layout) { + case 'frontend-only': return { shared: ['root'], client: ['root'], server: [] } + case 'backend-only': return { shared: ['root'], client: [], server: ['root'] } + case 'monorepo': return { shared: ['client', 'server'], client: ['client'], server: ['server'] } + default: return { shared: [], client: [], server: [] } + } +} + +const joinRel = (...parts: string[]): string => parts.filter(p => p.length > 0).join('/') + +export function planPlacement (config: ProjectConfig, capabilities: Capability[]): PlacementResult { + const layout = layoutOf(config.stack) + const ctx: CapabilityContext = { name: config.name, network: config.network, bsvDir: config.bsvDir, stack: config.stack, layout } + const roleTargets = roleTargetsFor(layout) + const utilByPath = new Map() + const glueByPath = new Map() + const deps: Record> = { root: {}, client: {}, server: {} } + + const add = (map: Map, path: string, content: string): void => { + const existing = map.get(path) + if (existing != null && existing.content !== content) throw new Error(`file conflict at ${path} between capabilities`) + map.set(path, { path, content }) + } + + for (const cap of capabilities) { + const roleFiles = cap.files(ctx) + const roleDeps = cap.npmDependencies(ctx) + for (const role of ROLES) { + const targets = roleTargets[role] + if (targets.length === 0) continue + const files = roleFiles[role] ?? [] + const rdeps = roleDeps[role] ?? {} + for (const t of targets) { + for (const f of files) add(utilByPath, joinRel(targetRoot(t), config.bsvDir, f.path), f.content) + Object.assign(deps[t], rdeps) + } + } + if (config.glue && cap.glue != null) { + const glue = cap.glue(ctx) + for (const role of ROLES) { + for (const t of roleTargets[role]) { + for (const f of glue[role] ?? []) add(glueByPath, joinRel(targetRoot(t), f.path), f.content) + } + } + } + } + return { utilFiles: [...utilByPath.values()], glueFiles: [...glueByPath.values()], deps } +} + +export interface WriteResult { written: string[], skipped: string[] } + +export function writeFiles (specs: FileSpec[], targetDir: string, opts: { force?: boolean } = {}): WriteResult { + const written: string[] = [] + const skipped: string[] = [] + for (const spec of specs) { + const abs = join(targetDir, spec.path) + const exists: boolean = existsSync(abs) + const shouldSkip: boolean = exists && opts.force !== true + if (shouldSkip) { + skipped.push(spec.path) + continue + } + mkdirSync(dirname(abs), { recursive: true }) + writeFileSync(abs, spec.content) + written.push(spec.path) + } + return { written, skipped } +} diff --git a/packages/helpers/create-bsv-app/src/index.ts b/packages/helpers/create-bsv-app/src/index.ts new file mode 100644 index 000000000..200c0136e --- /dev/null +++ b/packages/helpers/create-bsv-app/src/index.ts @@ -0,0 +1,19 @@ +#!/usr/bin/env node +import { run } from './cli.js' +import { interactiveConfigPrompt } from './prompts.js' +import { formatConfigError } from './config/validate.js' + +run(process.argv.slice(2), interactiveConfigPrompt) + .then((res) => { + const verb = res.skipped.length === 0 && res.written.length > 0 ? 'Scaffolded' : 'Updated' + console.log(`\n${verb} ${res.targetDir} (${res.written.length} file(s) written).`) + for (const [target, d] of Object.entries(res.deps)) { + const names = Object.keys(d) + if (names.length > 0) { + const cmd = target === 'root' ? 'npm i' : `cd ${target} && npm i` + console.log(`\nInstall deps${target === 'root' ? '' : ` (${target}/)`}:\n ${cmd}`) + } + } + console.log('\nSee AGENTS.md for wiring + how to extend.') + }) + .catch((err) => { console.error(formatConfigError(err)); process.exit(1) }) diff --git a/packages/helpers/create-bsv-app/src/pipeline.ts b/packages/helpers/create-bsv-app/src/pipeline.ts new file mode 100644 index 000000000..15282b985 --- /dev/null +++ b/packages/helpers/create-bsv-app/src/pipeline.ts @@ -0,0 +1,44 @@ +import type { ProjectConfig } from './config/model.js' +import type { TargetKey, WriteResult } from './engine.js' +import { writeFiles, planPlacement } from './engine.js' +import { resolveCapabilities } from './registry.js' +import { renderAgentsMd } from './agents-md.js' +import { manifestFromConfig, writeProjectManifest, MANIFEST_FILE } from './config/project-manifest.js' +import { scaffoldNewProject } from './scaffold/new-project.js' +import { applyCapabilityDeps } from './scaffold/package-json.js' +import type { RunCommand } from './scaffold/base-scaffolder.js' + +export interface RunResult { + targetDir: string + deps: Record> + written: string[] + skipped: string[] +} + +export function addCapabilities ( + config: ProjectConfig, + targetDir: string, + opts: { force: boolean } +): { deps: Record> } & WriteResult { + const caps = resolveCapabilities(config.capabilities, { expandRequires: false }) + const placement = planPlacement(config, caps) + const util = writeFiles(placement.utilFiles, targetDir, { force: opts.force }) + const glue = writeFiles(placement.glueFiles, targetDir, { force: true }) + const agents = writeFiles([{ path: 'AGENTS.md', content: renderAgentsMd(config, caps) }], targetDir, { force: true }) + writeProjectManifest(targetDir, manifestFromConfig(config)) + applyCapabilityDeps(targetDir, placement.deps) + return { deps: placement.deps, written: [...util.written, ...glue.written, ...agents.written, MANIFEST_FILE], skipped: util.skipped } +} + +export function applyConfig ( + config: ProjectConfig, + targetDir: string, + opts: { runCommand?: RunCommand, force?: boolean } +): RunResult { + if (config.mode === 'new') { + const r = scaffoldNewProject(config, targetDir, { runCommand: opts.runCommand }) + return { targetDir, deps: r.deps, written: r.written, skipped: [] } + } + const r = addCapabilities(config, targetDir, { force: opts.force ?? false }) + return { targetDir, deps: r.deps, written: r.written, skipped: r.skipped } +} diff --git a/packages/helpers/create-bsv-app/src/prompts.ts b/packages/helpers/create-bsv-app/src/prompts.ts new file mode 100644 index 000000000..a4876bbc9 --- /dev/null +++ b/packages/helpers/create-bsv-app/src/prompts.ts @@ -0,0 +1,65 @@ +import { listCapabilities } from './registry.js' +import { remainingCapabilityIds, mergeCapabilityIds } from './config/project-manifest.js' +import type { ProjectManifest } from './config/project-manifest.js' +import { configSchema, isFieldVisible } from './config/schema.js' +import type { ConfigField, FieldOption } from './config/schema.js' +import { seedDraft, resolveDraft } from './config/draft.js' +import type { ConfigDraft } from './config/draft.js' +import type { ProjectConfig } from './config/model.js' + +export type Ask = (field: ConfigField, options: FieldOption[]) => Promise +export type ConfigProvider = (ctx: { existing: ProjectManifest | null, flags: ConfigDraft }) => Promise + +function optionsFor (field: ConfigField, existing: ProjectManifest | null, mode: 'new' | 'add'): FieldOption[] { + if (field.key === 'capabilities') { + const all = listCapabilities() + let ids: string[] + if (mode === 'add') { + ids = existing != null ? remainingCapabilityIds(existing, all.map(c => c.id)) : all.map(c => c.id) + } else { + // new mode: defaultSelected base capabilities are always included (floor) → don't offer them as toggles + ids = all.filter(c => c.defaultSelected !== true).map(c => c.id) + } + return all.filter(c => ids.includes(c.id)).map(c => ({ value: c.id, label: c.title, hint: c.description })) + } + return field.options ?? [] +} + +export async function runPrompts (ctx: { existing: ProjectManifest | null, flags: ConfigDraft }, ask: Ask): Promise { + const draft: ConfigDraft = seedDraft(ctx.existing, ctx.flags) + for (const section of configSchema) { + for (const field of section.fields) { + if (field.key !== 'capabilities' && (draft as Record)[field.key] !== undefined) continue // set by flags/seed + if (!isFieldVisible(field, draft as Record)) continue + const value = await ask(field, optionsFor(field, ctx.existing, (draft.mode ?? 'new'))) + if (field.key === 'capabilities') { + (draft as Record).capabilities = mergeCapabilityIds((draft.capabilities as string[]) ?? [], value as string[]) + } else { + (draft as Record)[field.key] = value + } + } + } + return resolveDraft(draft) +} + +export const interactiveConfigPrompt: ConfigProvider = async (ctx) => { + const p = await import('@clack/prompts') // lazy: keep clack out of the Jest transform + p.intro('create-bsv-app') + const mode = ctx.flags.mode ?? (ctx.existing != null ? 'add' : 'new') + if (mode === 'new') { + const base = listCapabilities().filter(c => c.defaultSelected === true) + if (base.length > 0) p.note(base.map(c => `• ${c.title}`).join('\n'), 'Always included') + } + const ask: Ask = async (field, options) => { + let res: unknown + if (field.type === 'text') res = await p.text({ message: field.label, placeholder: typeof field.default === 'string' ? field.default : undefined }) + else if (field.type === 'toggle') res = await p.confirm({ message: field.label, initialValue: field.default === true }) + else if (field.type === 'multiselect') res = await p.multiselect({ message: field.label, options, required: false }) + else res = await p.select({ message: field.label, options }) + if (p.isCancel(res)) { p.cancel('Cancelled'); process.exit(1) } + return res + } + const config = await runPrompts(ctx, ask) + p.outro('Done') + return config +} diff --git a/packages/helpers/create-bsv-app/src/registry.ts b/packages/helpers/create-bsv-app/src/registry.ts new file mode 100644 index 000000000..52d697456 --- /dev/null +++ b/packages/helpers/create-bsv-app/src/registry.ts @@ -0,0 +1,29 @@ +// src/registry.ts +import type { Capability } from './types.js' +import { walletConnect } from './capabilities/wallet-connect.js' +import { walletLogin } from './capabilities/wallet-login.js' +import { signedRequests } from './capabilities/signed-requests.js' + +export const registry: Capability[] = [walletConnect, walletLogin, signedRequests] + +export function listCapabilities (): Capability[] { + return registry +} + +export function getCapability (id: string): Capability | undefined { + return registry.find(c => c.id === id) +} + +export function resolveCapabilities (ids: string[], opts: { expandRequires?: boolean } = {}): Capability[] { + const expand = opts.expandRequires !== false + const out: Capability[] = [] + const seen = new Set() + const visit = (id: string): void => { + const c = getCapability(id) + if (c === undefined) throw new Error(`unknown capability: ${id}`) + if (expand) for (const dep of c.requires ?? []) visit(dep) + if (!seen.has(id)) { seen.add(id); out.push(c) } + } + for (const id of ids) visit(id) + return out +} diff --git a/packages/helpers/create-bsv-app/src/scaffold/__tests__/base-app.test.ts b/packages/helpers/create-bsv-app/src/scaffold/__tests__/base-app.test.ts new file mode 100644 index 000000000..f16c4173d --- /dev/null +++ b/packages/helpers/create-bsv-app/src/scaffold/__tests__/base-app.test.ts @@ -0,0 +1,114 @@ +import { describe, expect, test, beforeEach, afterEach } from '@jest/globals' +import { mkdtempSync, rmSync, readFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { assembleBaseFile, assembleAndWrite, bsvImport, newBuilder, MAIN_TEMPLATE, APP_TEMPLATE } from '../base-app.js' + +const CTX = { name: 'd', network: 'test' as const, bsvDir: 'src/bsv', stack: {}, layout: 'monorepo' as const } + +describe('assembleBaseFile', () => { + test('fills imports + wrap, removes empty markers', () => { + const b = newBuilder() + b.main.imports.push("import { WalletProviders } from './bsv/WalletProviders'") + b.main.wraps.push({ open: '', close: '' }) + const out = assembleBaseFile(MAIN_TEMPLATE, b, CTX) + expect(out).toContain("import { WalletProviders } from './bsv/WalletProviders'") + expect(out).toContain('') + expect(out).toContain('') + expect(out).not.toContain('{{') // all markers consumed + }) + test('empty builder removes all markers, leaving valid base', () => { + const out = assembleBaseFile(MAIN_TEMPLATE, newBuilder(), CTX) + expect(out).not.toContain('{{') + expect(out).toContain('') + }) + test('wraps nest: opens in push order, closes reversed', () => { + const b = newBuilder() + b.main.wraps.push({ open: '', close: '' }) + b.main.wraps.push({ open: '', close: '' }) + const out = assembleBaseFile(MAIN_TEMPLATE, b, CTX) + expect(out.indexOf('')).toBeLessThan(out.indexOf('')) // A outermost open + expect(out.indexOf('')).toBeLessThan(out.indexOf('')) // B closes first + }) + test('route descriptor renders named import and JSX', () => { + const b = newBuilder() + b.app.routes.push({ path: '/a', component: 'A', importPath: './bsv/A' }) + const out = assembleBaseFile(APP_TEMPLATE, b, CTX) + expect(out).toContain("import { A } from './bsv/A'") + expect(out).toContain('} />') + }) + test('default Home import resolves to ./bsv with the default bsvDir', () => { + const out = assembleBaseFile(APP_TEMPLATE, newBuilder(), CTX) + expect(out).toContain("import { Home } from './bsv/Home'") + }) + test('non-default bsvDir rewrites the Home import path relative to src/', () => { + const out = assembleBaseFile(APP_TEMPLATE, newBuilder(), { ...CTX, bsvDir: 'lib/bsv' }) + expect(out).toContain("import { Home } from '../lib/bsv/Home'") + expect(out).not.toContain("'./bsv/Home'") + }) +}) + +describe('bsvImport', () => { + test("src/-prefixed bsvDir → './' relative path", () => { + expect(bsvImport(CTX, 'WalletProviders')).toBe('./bsv/WalletProviders') + expect(bsvImport({ ...CTX, bsvDir: 'src/helpers' }, 'Home')).toBe('./helpers/Home') + }) + test("non-src bsvDir → '../' relative path", () => { + expect(bsvImport({ ...CTX, bsvDir: 'lib/bsv' }, 'loginRoute.js')).toBe('../lib/bsv/loginRoute.js') + }) +}) + +describe('assembleAndWrite', () => { + let dir: string + beforeEach(() => { dir = mkdtempSync(join(tmpdir(), 'cba-base-')) }) + afterEach(() => { rmSync(dir, { recursive: true, force: true }) }) + const cap = (edit: (b: any) => void): any => ({ id: 'm', roles: [], files: () => ({}), npmDependencies: () => ({}), agentsSection: () => '', baseEdits: ({ builder }: any) => edit(builder) }) + const ctx = { name: 'd', network: 'test' as const, bsvDir: 'src/bsv', stack: {}, layout: 'monorepo' as const } + + test('writes main.tsx + App.tsx + Home to clientDir and index.ts + config to serverDir', () => { + const caps = [cap((b: any) => { + b.main.wraps.push({ open: '', close: '' }) + b.app.routes.push({ path: '/x', component: 'X', importPath: './bsv/X', label: 'X demo' }) + b.server.routes.push('app.get("/y", h)') + })] + const r = assembleAndWrite(caps, ctx, { clientDir: join(dir, 'client'), serverDir: join(dir, 'server') }) + const appTsx = readFileSync(join(dir, 'client/src/App.tsx'), 'utf8') + expect(appTsx).toContain('} />') // generated from the descriptor + expect(appTsx).toContain("import { X } from './bsv/X'") // import generated too + const serverIndex = readFileSync(join(dir, 'server/src/index.ts'), 'utf8') + expect(serverIndex).toContain('app.get("/y", h)') + // baseline identity route so clients never hard-code the server's key + expect(serverIndex).toContain("app.get('/api/identity'") + expect(serverIndex).toContain('getPublicKey({ identityKey: true })') + expect(serverIndex).toContain('cors({ origin: CLIENT_ORIGIN })') + // generated Home hub links to each capability route by its label + const home = readFileSync(join(dir, 'client/src/bsv/Home.tsx'), 'utf8') + expect(home).toContain('X demo →') + expect(home).toContain("import { Link } from 'react-router-dom'") + // server config centralizes env reads + const cfg = readFileSync(join(dir, 'server/src/bsv/config.ts'), 'utf8') + expect(cfg).toContain('SERVER_PRIVATE_KEY') + expect(cfg).toContain('CLIENT_ORIGIN') + expect(r.client).toEqual(expect.arrayContaining(['src/main.tsx', 'src/App.tsx', 'src/bsv/Home.tsx'])) + expect(r.server).toEqual(['src/index.ts', 'src/bsv/config.ts']) + }) + + test('multi-line insertions are indented to the marker column (main.tsx wrap, nested)', () => { + const caps = [cap((b: any) => { + b.main.imports.push("import { P } from './bsv/P'") + b.main.wraps.push({ open: '

', close: '

' }) + })] + assembleAndWrite(caps, ctx, { clientDir: join(dir, 'client') }) + const mainTsx = readFileSync(join(dir, 'client/src/main.tsx'), 'utf8') + //

at the marker's 4-space column, nested at 6, no column-0 ragged lines + expect(mainTsx).toContain('

\n \n

') + expect(mainTsx).not.toMatch(/^/m) + }) + + test('empty home links render a friendly message and omit the unused Link import', () => { + assembleAndWrite([], ctx, { clientDir: join(dir, 'client') }) + const home = readFileSync(join(dir, 'client/src/bsv/Home.tsx'), 'utf8') + expect(home).toContain('No capability demos installed.') + expect(home).not.toContain('import { Link }') + }) +}) diff --git a/packages/helpers/create-bsv-app/src/scaffold/__tests__/express-skeleton.test.ts b/packages/helpers/create-bsv-app/src/scaffold/__tests__/express-skeleton.test.ts new file mode 100644 index 000000000..bc4fc41aa --- /dev/null +++ b/packages/helpers/create-bsv-app/src/scaffold/__tests__/express-skeleton.test.ts @@ -0,0 +1,28 @@ +// src/scaffold/__tests__/express-skeleton.test.ts +import { describe, expect, test, beforeEach, afterEach } from '@jest/globals' +import { mkdtempSync, rmSync, readFileSync, existsSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { expressSkeletonScaffolder, scaffolderFor, viteScaffolder } from '../base-scaffolder' + +let dir: string +beforeEach(() => { dir = mkdtempSync(join(tmpdir(), 'cba-exp-')) }) +afterEach(() => { rmSync(dir, { recursive: true, force: true }) }) + +describe('expressSkeletonScaffolder', () => { + test('writes a runnable express-ts skeleton', () => { + expressSkeletonScaffolder.scaffold({ kind: 'backend', target: { framework: 'express' } }, dir, { packageManager: 'npm', runCommand: () => { throw new Error('should not run a command') } }) + expect(existsSync(join(dir, 'src/index.ts'))).toBe(true) + const pkg = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8')) + expect(pkg.dependencies).toHaveProperty('express') + expect(readFileSync(join(dir, 'src/index.ts'), 'utf8')).toContain("from 'express'") + expect(existsSync(join(dir, 'tsconfig.json'))).toBe(true) + }) +}) + +describe('scaffolderFor', () => { + test('selects vite for react and the skeleton for express', () => { + expect(scaffolderFor('react')).toBe(viteScaffolder) + expect(scaffolderFor('express')).toBe(expressSkeletonScaffolder) + }) +}) diff --git a/packages/helpers/create-bsv-app/src/scaffold/__tests__/new-project.test.ts b/packages/helpers/create-bsv-app/src/scaffold/__tests__/new-project.test.ts new file mode 100644 index 000000000..ae74fa671 --- /dev/null +++ b/packages/helpers/create-bsv-app/src/scaffold/__tests__/new-project.test.ts @@ -0,0 +1,85 @@ +// src/scaffold/__tests__/new-project.test.ts +import { describe, expect, test, beforeEach, afterEach } from '@jest/globals' +import { mkdtempSync, rmSync, existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { scaffoldNewProject } from '../new-project' +import type { RunCommand } from '../base-scaffolder' +import type { ProjectConfig } from '../../config/model' + +let base: string +beforeEach(() => { base = mkdtempSync(join(tmpdir(), 'cba-np-')) }) +afterEach(() => { rmSync(base, { recursive: true, force: true }) }) + +function cfg (over: Partial): ProjectConfig { + return { mode: 'new', name: 'demo', dir: '.', stack: {}, bsvDir: 'src/bsv', capabilities: ['wallet-login'], glue: false, packageManager: 'npm', network: 'test', ...over } +} + +describe('scaffoldNewProject', () => { + test('frontend-only: runs vite (recorded), places react capability files', () => { + const dir = join(base, 'app') + const calls: string[][] = [] + const fake: RunCommand = (command, args) => { calls.push([command, ...args]) } + scaffoldNewProject(cfg({ stack: { frontend: { framework: 'react', variant: 'react-ts' } } }), dir, { runCommand: fake }) + expect(calls.some(c => c.includes('vite@latest'))).toBe(true) + expect(existsSync(join(dir, 'src/bsv/auth.ts'))).toBe(true) + expect(existsSync(join(dir, 'src/bsv/useWalletLogin.tsx'))).toBe(true) + expect(JSON.parse(readFileSync(join(dir, 'bsv-scaffold.json'), 'utf8')).stack.frontend.framework).toBe('react') + }) + + test('backend-only: writes express skeleton + server capability files (no command)', () => { + const dir = join(base, 'api') + const fake: RunCommand = () => { throw new Error('no command expected') } + scaffoldNewProject(cfg({ stack: { backend: { framework: 'express' } } }), dir, { runCommand: fake }) + expect(existsSync(join(dir, 'src/index.ts'))).toBe(true) // express skeleton + expect(existsSync(join(dir, 'src/bsv/auth.ts'))).toBe(true) + expect(existsSync(join(dir, 'src/bsv/loginRoute.ts'))).toBe(true) + }) + + test('monorepo: client/ (vite) + server/ (skeleton) + duplicated shared, independent packages (no root workspace)', () => { + const dir = join(base, 'full') + const fake: RunCommand = () => {} + scaffoldNewProject(cfg({ stack: { frontend: { framework: 'react', variant: 'react-ts' }, backend: { framework: 'express' } } }), dir, { runCommand: fake }) + expect(existsSync(join(dir, 'server/src/index.ts'))).toBe(true) + expect(existsSync(join(dir, 'client/src/bsv/auth.ts'))).toBe(true) + expect(existsSync(join(dir, 'server/src/bsv/auth.ts'))).toBe(true) // shared duplicated + expect(existsSync(join(dir, 'server/src/bsv/loginRoute.ts'))).toBe(true) + // Independent packages: no root package.json / workspace stitching them together. + expect(existsSync(join(dir, 'package.json'))).toBe(false) + expect(existsSync(join(dir, 'pnpm-workspace.yaml'))).toBe(false) + }) + + test('throws on a non-empty target dir', () => { + const dir = join(base, 'taken') + mkdirSync(dir); writeFileSync(join(dir, 'x.txt'), 'hi') + expect(() => scaffoldNewProject(cfg({ stack: { backend: { framework: 'express' } } }), dir, { runCommand: () => {} })).toThrow(/not empty/i) + }) + + test('new-mode monorepo with wallet-connect assembles main.tsx, App.tsx, index.ts via assembleAndWrite', () => { + const dir = join(base, 'wallet') + const fake: RunCommand = () => {} + const result = scaffoldNewProject(cfg({ + stack: { frontend: { framework: 'react', variant: 'react-ts' }, backend: { framework: 'express' } }, + capabilities: ['wallet-connect'], + glue: true + }), dir, { runCommand: fake }) + + // main.tsx wraps in + const mainTsx = readFileSync(join(dir, 'client/src/main.tsx'), 'utf8') + expect(mainTsx).toContain('') + + // App.tsx contains the Home route + const appTsx = readFileSync(join(dir, 'client/src/App.tsx'), 'utf8') + expect(appTsx).toContain(' { dir = mkdtempSync(join(tmpdir(), 'cba-pkg-')) }) +afterEach(() => { rmSync(dir, { recursive: true, force: true }) }) + +test('adds missing deps, preserves existing version, leaves devDeps alone', () => { + writeFileSync(join(dir, 'package.json'), JSON.stringify({ name: 'x', dependencies: { react: '^19.0.0' }, devDependencies: { typescript: '^6.0.0' } }), 'utf8') + mergePackageJsonDeps(dir, { '@bsv/auth': '^0.1.0', react: '>=18', typescript: '^9.9.9' }) + const pkg = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8')) + expect(pkg.dependencies['@bsv/auth']).toBe('^0.1.0') // added + expect(pkg.dependencies.react).toBe('^19.0.0') // preserved (not downgraded) + expect(pkg.dependencies.typescript).toBeUndefined() // already a devDep +}) + +test('creates a minimal package.json when none exists', () => { + mergePackageJsonDeps(dir, { '@bsv/auth': '^0.1.0' }) + expect(existsSync(join(dir, 'package.json'))).toBe(true) + const pkg = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8')) + expect(pkg.dependencies['@bsv/auth']).toBe('^0.1.0') +}) + +test('empty deps is a no-op (no file created)', () => { + mergePackageJsonDeps(dir, {}) + expect(existsSync(join(dir, 'package.json'))).toBe(false) +}) + +describe('applyCapabilityDeps integration', () => { + test('merges deps into root, client, and server dirs', () => { + const clientDir = join(dir, 'client') + const serverDir = join(dir, 'server') + // pre-create client package.json with react already present + mkdirSync(clientDir, { recursive: true }) + writeFileSync(join(clientDir, 'package.json'), JSON.stringify({ dependencies: { react: '^19.0.0' } }), 'utf8') + applyCapabilityDeps(dir, { + root: {}, + client: { '@bsv/sdk': '^1.0.0', react: '>=18' }, + server: { '@bsv/auth': '^0.1.0' } + }) + // root: empty → no file created + expect(existsSync(join(dir, 'package.json'))).toBe(false) + // client: @bsv/sdk added, react preserved + const clientPkg = JSON.parse(readFileSync(join(clientDir, 'package.json'), 'utf8')) + expect(clientPkg.dependencies['@bsv/sdk']).toBe('^1.0.0') + expect(clientPkg.dependencies.react).toBe('^19.0.0') + // server: @bsv/auth added (package.json created) + expect(existsSync(join(serverDir, 'package.json'))).toBe(true) + const serverPkg = JSON.parse(readFileSync(join(serverDir, 'package.json'), 'utf8')) + expect(serverPkg.dependencies['@bsv/auth']).toBe('^0.1.0') + }) +}) diff --git a/packages/helpers/create-bsv-app/src/scaffold/__tests__/run-command.test.ts b/packages/helpers/create-bsv-app/src/scaffold/__tests__/run-command.test.ts new file mode 100644 index 000000000..363a8b6e3 --- /dev/null +++ b/packages/helpers/create-bsv-app/src/scaffold/__tests__/run-command.test.ts @@ -0,0 +1,28 @@ +import { expect, test } from '@jest/globals' +import type { SpawnSyncOptions } from 'node:child_process' +import { makeRunCommand } from '../run-command' + +test('runs with stdin NOT inherited so interactive generators stay non-interactive', () => { + let captured: SpawnSyncOptions | undefined + const run = makeRunCommand((_c, _a, options) => { captured = options; return { status: 0 } }) + run('npm', ['create', 'vite@latest', 'app'], { cwd: '/tmp/x' }) + expect(captured?.stdio).toEqual(['ignore', 'inherit', 'inherit']) + expect(captured?.cwd).toBe('/tmp/x') +}) + +test('forwards command and args verbatim', () => { + const seen: Array<{ command: string, args: string[] }> = [] + const run = makeRunCommand((command, args) => { seen.push({ command, args }); return { status: 0 } }) + run('pnpm', ['create', 'vite@latest', 'app', '--template', 'react-ts'], { cwd: '.' }) + expect(seen).toEqual([{ command: 'pnpm', args: ['create', 'vite@latest', 'app', '--template', 'react-ts'] }]) +}) + +test('throws on non-zero exit status', () => { + const run = makeRunCommand(() => ({ status: 1 })) + expect(() => run('npm', ['x'], { cwd: '.' })).toThrow(/command failed \(1\)/) +}) + +test('throws on spawn error', () => { + const run = makeRunCommand(() => ({ status: null, error: new Error('ENOENT') })) + expect(() => run('nope', [], { cwd: '.' })).toThrow(/ENOENT/) +}) diff --git a/packages/helpers/create-bsv-app/src/scaffold/__tests__/vite.test.ts b/packages/helpers/create-bsv-app/src/scaffold/__tests__/vite.test.ts new file mode 100644 index 000000000..461ce5e9f --- /dev/null +++ b/packages/helpers/create-bsv-app/src/scaffold/__tests__/vite.test.ts @@ -0,0 +1,27 @@ +// src/scaffold/__tests__/vite.test.ts +import { describe, expect, test } from '@jest/globals' +import { viteScaffolder, viteCommand } from '../vite' +import type { RunCommand } from '../base-scaffolder' + +describe('viteCommand', () => { + test('npm uses the -- separator before --template and --eslint, run from the parent dir', () => { + expect(viteCommand('npm', '/proj/client', 'react-ts')).toEqual({ + command: 'npm', args: ['create', 'vite@latest', 'client', '--', '--template', 'react-ts', '--eslint'], cwd: '/proj' + }) + }) + test('pnpm/yarn/bun omit the -- separator and pass --eslint', () => { + expect(viteCommand('pnpm', '/proj/client', 'react-ts').args).toEqual(['create', 'vite@latest', 'client', '--template', 'react-ts', '--eslint']) + expect(viteCommand('yarn', '/proj/client', 'react-ts').args).toEqual(['create', 'vite', 'client', '--template', 'react-ts', '--eslint']) + expect(viteCommand('bun', '/proj/client', 'react-ts').args).toEqual(['create', 'vite@latest', 'client', '--template', 'react-ts', '--eslint']) + }) +}) + +describe('viteScaffolder', () => { + test('invokes the injected runCommand with the computed vite command', () => { + const calls: Array<{ command: string, args: string[], cwd: string }> = [] + const fake: RunCommand = (command, args, opts) => { calls.push({ command, args, cwd: opts.cwd }) } + viteScaffolder.scaffold({ kind: 'frontend', target: { framework: 'react', variant: 'react-ts' } }, '/proj/client', { packageManager: 'npm', runCommand: fake }) + expect(calls).toHaveLength(1) + expect(calls[0]).toEqual({ command: 'npm', args: ['create', 'vite@latest', 'client', '--', '--template', 'react-ts', '--eslint'], cwd: '/proj' }) + }) +}) diff --git a/packages/helpers/create-bsv-app/src/scaffold/base-app.ts b/packages/helpers/create-bsv-app/src/scaffold/base-app.ts new file mode 100644 index 000000000..8fedfc206 --- /dev/null +++ b/packages/helpers/create-bsv-app/src/scaffold/base-app.ts @@ -0,0 +1,190 @@ +import { mkdirSync, writeFileSync } from 'node:fs' +import { dirname, join } from 'node:path' +import type { Capability, CapabilityContext, BaseBuilder, RouteDef } from '../types.js' + +export function newBuilder (): BaseBuilder { + return { main: { imports: [], wraps: [] }, app: { imports: [], routes: [] }, server: { imports: [], routes: [] } } +} + +// Relative import specifier from a base file (at `/src/`) to a glue file +// under `ctx.bsvDir`. Default bsvDir 'src/bsv' → './bsv/'; e.g. 'lib/bsv' → '../lib/bsv/'. +export function bsvImport (ctx: CapabilityContext, name: string): string { + const rel = ctx.bsvDir.startsWith('src/') ? './' + ctx.bsvDir.slice('src/'.length) : '../' + ctx.bsvDir + return `${rel}/${name}` +} + +export const MAIN_TEMPLATE = `import { StrictMode } from 'react' +import { createRoot } from 'react-dom/client' +import './index.css' +import App from './App.tsx' +/*{{main.imports}}*/ + +createRoot(document.getElementById('root')!).render( + + {/*{{main.app}}*/} + +) +` + +export const APP_TEMPLATE = `import { BrowserRouter, Routes, Route } from 'react-router-dom' +import { Home } from './bsv/Home' +/*{{app.imports}}*/ + +export default function App () { + return ( + + + } /> + {/*{{app.routes}}*/} + + + ) +} +` + +export const SERVER_TEMPLATE = `import express from 'express' +import cors from 'cors' +import { ProtoWallet, PrivateKey } from '@bsv/sdk' +import { SERVER_PRIVATE_KEY, PORT, CLIENT_ORIGIN } from './bsv/config.js' +/*{{server.imports}}*/ + +const app = express() +app.use(cors({ origin: CLIENT_ORIGIN })) // allow the browser client (different dev origin) to call the API +app.use(express.json()) + +// Verify-only server wallet. All config (incl. SERVER_PRIVATE_KEY) lives in bsv/config.ts. +const serverWallet = new ProtoWallet(PrivateKey.fromString(SERVER_PRIVATE_KEY)) + +app.get('/health', (_req, res) => { res.json({ status: 'ok' }) }) + +// The server's identity public key. Clients fetch this and use it as the proof +// \`counterparty\` (login / signed requests) — no need to hard-code a key anywhere. +app.get('/api/identity', async (_req, res) => { + const { publicKey } = await serverWallet.getPublicKey({ identityKey: true }) + res.json({ identityKey: publicKey }) +}) +/*{{server.routes}}*/ + +app.listen(PORT, () => { console.log(\`server on http://localhost:\${PORT}\`) }) +` + +// Baseline server config — every env the server reads, in one place. +export const SERVER_CONFIG = `// Centralized server configuration, read from the environment. +import { PrivateKey } from '@bsv/sdk' + +// Server wallet key. Set SERVER_PRIVATE_KEY for a stable identity; a random key is +// used as a dev fallback (the server's identity then changes on every restart). +export const SERVER_PRIVATE_KEY = process.env.SERVER_PRIVATE_KEY ?? PrivateKey.fromRandom().toString() + +export const PORT = Number(process.env.PORT ?? 3000) + +// Browser origin allowed by CORS — your client's dev URL by default. +export const CLIENT_ORIGIN = process.env.CLIENT_ORIGIN ?? 'http://localhost:5173' +` + +// Default home: connect a wallet, then link out to each installed capability's demo page. +export const HOME_TEMPLATE = `import { ConnectWallet } from './ConnectWallet.js' +import { useWallet } from './WalletContext.js' +/*{{home.imports}}*/ + +export function Home () { + const { connected } = useWallet() + return ( +
+

BSV app

+

Connect a wallet to get started, then try the installed demos.

+ + {connected && ( + + )} +
+ ) +} +` + +// Build the wrapped-app JSX: opens in push order, , closes reversed — each +// nesting level indented +2 (the marker's own column is added on top by assembleBaseFile). +function wrappedApp (wraps: Array<{ open: string, close: string }>): string { + if (wraps.length === 0) return '' + const lines: string[] = [] + wraps.forEach((w, i) => lines.push(' '.repeat(i) + w.open)) + lines.push(' '.repeat(wraps.length) + '') + for (let i = wraps.length - 1; i >= 0; i--) lines.push(' '.repeat(i) + wraps[i].close) + return lines.join('\n') +} + +// Render route descriptors → named imports + JSX (scaffolder owns the JSX). +export function routeImports (routes: RouteDef[]): string { + return routes.map(r => `import { ${r.component} } from '${r.importPath}'`).join('\n') +} + +export function routeJsx (routes: RouteDef[]): string { + return routes.map(r => `} />`).join('\n') +} + +// Home demo-hub links, one per capability route (label falls back to the path). +function homeLinks (routes: RouteDef[]): string { + if (routes.length === 0) return '

No capability demos installed.

' + return routes.map(r => `${r.label ?? r.path} →`).join('\n') +} + +export function assembleBaseFile (template: string, b: BaseBuilder, ctx: CapabilityContext): string { + let out = template + // Replace each marker, indenting every line after the first to the marker's own + // column so multi-line insertions (wraps, routes, links) stay aligned. + const sub = (marker: string, value: string): void => { + let idx = out.indexOf(marker) + while (idx !== -1) { + const lineStart = out.lastIndexOf('\n', idx) + 1 + const indent = out.slice(lineStart, idx) + const indented = /^[ \t]*$/.test(indent) ? value.split('\n').join('\n' + indent) : value + out = out.slice(0, idx) + indented + out.slice(idx + marker.length) + idx = out.indexOf(marker, idx + indented.length) + } + } + // app imports = explicit imports + one generated import per route descriptor + const appImports = [...b.app.imports, routeImports(b.app.routes)].filter(s => s.length > 0).join('\n') + // Generated base files (Home, server entry) live at /src/ but import glue from bsvDir. + sub("'./bsv/Home'", `'${bsvImport(ctx, 'Home')}'`) + sub("'./bsv/config.js'", `'${bsvImport(ctx, 'config.js')}'`) + sub('/*{{main.imports}}*/', b.main.imports.join('\n')) + sub('{/*{{main.app}}*/}', wrappedApp(b.main.wraps)) + sub('/*{{app.imports}}*/', appImports) + sub('{/*{{app.routes}}*/}', routeJsx(b.app.routes)) + sub('/*{{server.imports}}*/', b.server.imports.join('\n')) + sub('/*{{server.routes}}*/', b.server.routes.join('\n')) + sub('/*{{home.imports}}*/', b.app.routes.length > 0 ? "import { Link } from 'react-router-dom'" : '') + sub('{/*{{home.links}}*/}', homeLinks(b.app.routes)) + return out + .replace(/[ \t]+$/gm, '') // drop trailing whitespace left by removed markers + .replace(/\n{3,}/g, '\n\n') // collapse blank-line runs +} + +export function assembleAndWrite ( + caps: Capability[], + ctx: CapabilityContext, + dirs: { clientDir?: string, serverDir?: string } +): { client: string[], server: string[] } { + const builder = newBuilder() + for (const cap of caps) cap.baseEdits?.({ builder, ctx }) + const result: { client: string[], server: string[] } = { client: [], server: [] } + const write = (dir: string, rel: string, content: string, bucket: string[]): void => { + const abs = join(dir, rel) + mkdirSync(dirname(abs), { recursive: true }) + writeFileSync(abs, content) + bucket.push(rel) + } + if (dirs.clientDir != null) { + write(dirs.clientDir, 'src/main.tsx', assembleBaseFile(MAIN_TEMPLATE, builder, ctx), result.client) + write(dirs.clientDir, 'src/App.tsx', assembleBaseFile(APP_TEMPLATE, builder, ctx), result.client) + write(dirs.clientDir, `${ctx.bsvDir}/Home.tsx`, assembleBaseFile(HOME_TEMPLATE, builder, ctx), result.client) + } + if (dirs.serverDir != null) { + write(dirs.serverDir, 'src/index.ts', assembleBaseFile(SERVER_TEMPLATE, builder, ctx), result.server) + write(dirs.serverDir, `${ctx.bsvDir}/config.ts`, SERVER_CONFIG, result.server) + } + return result +} diff --git a/packages/helpers/create-bsv-app/src/scaffold/base-scaffolder.ts b/packages/helpers/create-bsv-app/src/scaffold/base-scaffolder.ts new file mode 100644 index 000000000..5b103e10e --- /dev/null +++ b/packages/helpers/create-bsv-app/src/scaffold/base-scaffolder.ts @@ -0,0 +1,20 @@ +// src/scaffold/base-scaffolder.ts +import type { FrontendTarget, BackendTarget, PackageManager } from '../config/model.js' +import { viteScaffolder } from './vite.js' +import { expressSkeletonScaffolder } from './express-skeleton.js' + +export type TargetSpec = + | { kind: 'frontend', target: FrontendTarget } + | { kind: 'backend', target: BackendTarget } + +export type RunCommand = (command: string, args: string[], opts: { cwd: string }) => void + +export interface BaseScaffolder { + scaffold: (spec: TargetSpec, absDir: string, opts: { packageManager: PackageManager, runCommand: RunCommand }) => void +} + +export function scaffolderFor (framework: 'react' | 'express'): BaseScaffolder { + return framework === 'react' ? viteScaffolder : expressSkeletonScaffolder +} + +export { viteScaffolder, expressSkeletonScaffolder } diff --git a/packages/helpers/create-bsv-app/src/scaffold/express-skeleton.ts b/packages/helpers/create-bsv-app/src/scaffold/express-skeleton.ts new file mode 100644 index 000000000..31b2df222 --- /dev/null +++ b/packages/helpers/create-bsv-app/src/scaffold/express-skeleton.ts @@ -0,0 +1,55 @@ +// src/scaffold/express-skeleton.ts +import { basename } from 'node:path' +import { writeFiles } from '../engine.js' +import type { FileSpec } from '../types.js' +import type { BaseScaffolder } from './base-scaffolder.js' + +function files (name: string): FileSpec[] { + const pkg = { + name, + private: true, + type: 'module', + scripts: { dev: 'tsx watch src/index.ts', build: 'tsc', start: 'node dist/index.js' }, + dependencies: { express: '^5.0.0', cors: '^2.8.5' }, + devDependencies: { '@types/express': '^5.0.0', '@types/cors': '^2.8.17', tsx: '^4.19.0', typescript: '^6.0.3' } + } + const tsconfig = { + compilerOptions: { + target: 'ES2022', + module: 'NodeNext', + moduleResolution: 'NodeNext', + esModuleInterop: true, + outDir: './dist', + rootDir: './src', + strict: true, + skipLibCheck: true + }, + include: ['src/**/*.ts'] + } + const index = `import express from 'express' + +const app = express() +app.use(express.json()) + +app.get('/health', (_req, res) => { + res.json({ status: 'ok' }) +}) + +const PORT = Number(process.env.PORT ?? 3000) +app.listen(PORT, () => { + console.log(\`server listening on http://localhost:\${PORT}\`) +}) +` + return [ + { path: 'package.json', content: JSON.stringify(pkg, null, 2) + '\n' }, + { path: 'tsconfig.json', content: JSON.stringify(tsconfig, null, 2) + '\n' }, + { path: 'src/index.ts', content: index } + ] +} + +export const expressSkeletonScaffolder: BaseScaffolder = { + scaffold (spec, absDir, _opts) { + if (spec.kind !== 'backend') throw new Error('expressSkeletonScaffolder handles only backend targets') + writeFiles(files(basename(absDir)), absDir, { force: false }) + } +} diff --git a/packages/helpers/create-bsv-app/src/scaffold/new-project.ts b/packages/helpers/create-bsv-app/src/scaffold/new-project.ts new file mode 100644 index 000000000..8b4fdc585 --- /dev/null +++ b/packages/helpers/create-bsv-app/src/scaffold/new-project.ts @@ -0,0 +1,82 @@ +// src/scaffold/new-project.ts +import { existsSync, readdirSync, mkdirSync } from 'node:fs' +import { join } from 'node:path' +import type { ProjectConfig } from '../config/model.js' +import { layoutOf } from '../config/model.js' +import { planPlacement, writeFiles, type TargetKey } from '../engine.js' +import { resolveCapabilities } from '../registry.js' +import { renderAgentsMd } from '../agents-md.js' +import { manifestFromConfig, writeProjectManifest, MANIFEST_FILE } from '../config/project-manifest.js' +import { scaffolderFor, type RunCommand } from './base-scaffolder.js' +import { defaultRunCommand } from './run-command.js' +import { applyCapabilityDeps } from './package-json.js' +import type { CapabilityContext } from '../types.js' +import { assembleAndWrite } from './base-app.js' + +// Entries that don't count against an "empty" target dir: +// - .git: a fresh `git init` (or cloned empty repo) is a common pre-scaffold step. +// - bsv-scaffold.json: the spec a new project can be reproduced from; rewritten at the end. +const IGNORED_WHEN_EMPTY = new Set(['.git', MANIFEST_FILE]) + +function ensureEmpty (dir: string): void { + if (!existsSync(dir)) return + const blocking = readdirSync(dir).filter(e => !IGNORED_WHEN_EMPTY.has(e)) + if (blocking.length > 0) { + throw new Error( + `target directory is not empty: ${dir} — new projects scaffold into an empty directory ` + + `(an existing .git or ${MANIFEST_FILE} is allowed). To extend an existing project, run in add mode ` + + '("mode": "add" / --mode add); to scaffold fresh, clear the directory or target an empty --dir.' + ) + } +} + +export function scaffoldNewProject ( + config: ProjectConfig, + targetDir: string, + deps: { runCommand?: RunCommand } = {} +): { written: string[], deps: Record> } { + const runCommand = deps.runCommand ?? defaultRunCommand + const pm = config.packageManager + const layout = layoutOf(config.stack) + + ensureEmpty(targetDir) + mkdirSync(targetDir, { recursive: true }) + + const fe = config.stack.frontend + const be = config.stack.backend + + if (layout === 'monorepo') { + // Independent packages: client/ and server/ are standalone (own package.json, + // node_modules, lockfile) — no root workspace, so neither app can resolve the + // other's deps and each is deployable on its own. + if (fe != null) scaffolderFor('react').scaffold({ kind: 'frontend', target: fe }, join(targetDir, 'client'), { packageManager: pm, runCommand }) + if (be != null) scaffolderFor('express').scaffold({ kind: 'backend', target: be }, join(targetDir, 'server'), { packageManager: pm, runCommand }) + } else if (fe != null) { + scaffolderFor('react').scaffold({ kind: 'frontend', target: fe }, targetDir, { packageManager: pm, runCommand }) + } else if (be != null) { + scaffolderFor('express').scaffold({ kind: 'backend', target: be }, targetDir, { packageManager: pm, runCommand }) + } + + const caps = resolveCapabilities(config.capabilities) + const placement = planPlacement(config, caps) + const util = writeFiles(placement.utilFiles, targetDir, { force: false }) + const glue = writeFiles(placement.glueFiles, targetDir, { force: true }) + const agents = writeFiles([{ path: 'AGENTS.md', content: renderAgentsMd(config, caps) }], targetDir, { force: true }) + const written: string[] = [...util.written, ...glue.written, ...agents.written] + + if (config.glue && layout !== 'none') { + const ctx: CapabilityContext = { name: config.name, network: config.network, bsvDir: config.bsvDir, stack: config.stack, layout } + const clientDir = layout === 'monorepo' ? join(targetDir, 'client') : (layout === 'frontend-only' ? targetDir : undefined) + const serverDir = layout === 'monorepo' ? join(targetDir, 'server') : (layout === 'backend-only' ? targetDir : undefined) + const r = assembleAndWrite(caps, ctx, { clientDir, serverDir }) + const cp = layout === 'monorepo' ? 'client/' : '' + const sp = layout === 'monorepo' ? 'server/' : '' + written.push(...r.client.map(p => cp + p), ...r.server.map(p => sp + p)) + } + + writeProjectManifest(targetDir, { ...manifestFromConfig(config), capabilities: caps.map(c => c.id) }) + written.push(MANIFEST_FILE) + applyCapabilityDeps(targetDir, placement.deps) + + return { written, deps: placement.deps } +} diff --git a/packages/helpers/create-bsv-app/src/scaffold/package-json.ts b/packages/helpers/create-bsv-app/src/scaffold/package-json.ts new file mode 100644 index 000000000..5cc328cb6 --- /dev/null +++ b/packages/helpers/create-bsv-app/src/scaffold/package-json.ts @@ -0,0 +1,25 @@ +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' +import { dirname, join } from 'node:path' +import type { TargetKey } from '../engine.js' + +export function mergePackageJsonDeps (dir: string, deps: Record): void { + const names = Object.keys(deps) + if (names.length === 0) return + const file = join(dir, 'package.json') + const pkg: Record = existsSync(file) ? JSON.parse(readFileSync(file, 'utf8')) : {} + const dependencies: Record = (pkg.dependencies as Record) ?? {} + const devDependencies: Record = (pkg.devDependencies as Record) ?? {} + for (const name of names) { + if (dependencies[name] === undefined && devDependencies[name] === undefined) dependencies[name] = deps[name] + } + pkg.dependencies = dependencies + mkdirSync(dirname(file), { recursive: true }) + writeFileSync(file, JSON.stringify(pkg, null, 2) + '\n') +} + +export function applyCapabilityDeps (targetDir: string, deps: Record>): void { + const dirForTarget = (key: TargetKey): string => (key === 'root' ? targetDir : join(targetDir, key)) + for (const key of ['root', 'client', 'server'] as TargetKey[]) { + mergePackageJsonDeps(dirForTarget(key), deps[key]) + } +} diff --git a/packages/helpers/create-bsv-app/src/scaffold/run-command.ts b/packages/helpers/create-bsv-app/src/scaffold/run-command.ts new file mode 100644 index 000000000..a0890f93f --- /dev/null +++ b/packages/helpers/create-bsv-app/src/scaffold/run-command.ts @@ -0,0 +1,24 @@ +// src/scaffold/run-command.ts +import { spawnSync, type SpawnSyncOptions } from 'node:child_process' +import type { RunCommand } from './base-scaffolder.js' + +export interface SpawnResult { status: number | null, error?: Error } +export type SpawnSyncFn = (command: string, args: string[], options: SpawnSyncOptions) => SpawnResult + +export function makeRunCommand (spawn: SpawnSyncFn): RunCommand { + return (command, args, opts) => { + const res = spawn(command, args, { + cwd: opts.cwd, + // stdin is NOT inherited: clack-based generators (e.g. create-vite) fall back to + // their non-interactive defaults with the flags we pass, instead of prompting on the + // TTY. A prompt here would block (or be cancelled), throw, and abort the rest of + // scaffolding. stdout/stderr stay inherited so the user still sees progress. + stdio: ['ignore', 'inherit', 'inherit'], + shell: process.platform === 'win32' // npm/pnpm/yarn/bun are .cmd shims on Windows + }) + if (res.error != null) throw res.error + if (res.status !== 0) throw new Error(`command failed (${String(res.status)}): ${command} ${args.join(' ')}`) + } +} + +export const defaultRunCommand: RunCommand = makeRunCommand((command, args, options) => spawnSync(command, args, options)) diff --git a/packages/helpers/create-bsv-app/src/scaffold/vite.ts b/packages/helpers/create-bsv-app/src/scaffold/vite.ts new file mode 100644 index 000000000..7cb1b7797 --- /dev/null +++ b/packages/helpers/create-bsv-app/src/scaffold/vite.ts @@ -0,0 +1,23 @@ +// src/scaffold/vite.ts +import { basename, dirname } from 'node:path' +import type { PackageManager } from '../config/model.js' +import type { BaseScaffolder } from './base-scaffolder.js' + +export function viteCommand (pm: PackageManager, dir: string, variant: string): { command: string, args: string[], cwd: string } { + // create-vite scaffolds into a folder named relative to cwd; run from the parent. + const name = basename(dir) + const cwd = dirname(dir) + // --eslint: use ESLint instead of create-vite's default Oxlint for React templates. + if (pm === 'npm') return { command: 'npm', args: ['create', 'vite@latest', name, '--', '--template', variant, '--eslint'], cwd } + if (pm === 'yarn') return { command: 'yarn', args: ['create', 'vite', name, '--template', variant, '--eslint'], cwd } + // pnpm and bun + return { command: pm, args: ['create', 'vite@latest', name, '--template', variant, '--eslint'], cwd } +} + +export const viteScaffolder: BaseScaffolder = { + scaffold (spec, absDir, opts) { + if (spec.kind !== 'frontend') throw new Error('viteScaffolder handles only frontend targets') + const { command, args, cwd } = viteCommand(opts.packageManager, absDir, spec.target.variant) + opts.runCommand(command, args, { cwd }) + } +} diff --git a/packages/helpers/create-bsv-app/src/types.ts b/packages/helpers/create-bsv-app/src/types.ts new file mode 100644 index 000000000..03bea5e85 --- /dev/null +++ b/packages/helpers/create-bsv-app/src/types.ts @@ -0,0 +1,48 @@ +// src/types.ts +import type { Network, Stack, Layout } from './config/model.js' + +export interface FileSpec { + /** Relative POSIX path within the target project */ + path: string + content: string +} + +export type Role = 'shared' | 'client' | 'server' + +export interface CapabilityContext { + name: string + network: Network + bsvDir: string + stack: Stack + layout: Layout +} + +export interface RouteDef { + path: string + component: string + importPath: string + /** Human label for the Home demo hub (falls back to the path). */ + label?: string +} + +export interface BaseBuilder { + main: { imports: string[], wraps: Array<{ open: string, close: string }> } + app: { imports: string[], routes: RouteDef[] } + server: { imports: string[], routes: string[] } +} + +export interface Capability { + id: string + title: string + description: string + /** ids of other capabilities that must also be installed */ + requires?: string[] + roles: Role[] + /** Pre-selected in NEW-project mode (not auto-selected in add mode). */ + defaultSelected?: boolean + files: (ctx: CapabilityContext) => Partial> + glue?: (ctx: CapabilityContext) => Partial> + baseEdits?: (args: { builder: BaseBuilder, ctx: CapabilityContext }) => void + npmDependencies: (ctx: CapabilityContext) => Partial>> + agentsSection: (ctx: CapabilityContext) => string +} diff --git a/packages/helpers/create-bsv-app/src/ui/__tests__/open-browser.test.ts b/packages/helpers/create-bsv-app/src/ui/__tests__/open-browser.test.ts new file mode 100644 index 000000000..46ae09752 --- /dev/null +++ b/packages/helpers/create-bsv-app/src/ui/__tests__/open-browser.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, test } from '@jest/globals' +import { openBrowser } from '../open-browser' + +function capture (platform: NodeJS.Platform): { command: string, args: string[] } { + let got: { command: string, args: string[] } | null = null + openBrowser('http://127.0.0.1:5000', { platform, spawn: (command, args) => { got = { command, args } } }) + if (got === null) throw new Error('spawn was not called') + return got +} + +describe('openBrowser', () => { + test('win32 uses cmd /c start', () => { + const { command, args } = capture('win32') + expect(command).toBe('cmd') + expect(args).toEqual(['/c', 'start', '', 'http://127.0.0.1:5000']) + }) + test('darwin uses open', () => { + const { command, args } = capture('darwin') + expect(command).toBe('open') + expect(args).toEqual(['http://127.0.0.1:5000']) + }) + test('linux uses xdg-open', () => { + const { command, args } = capture('linux') + expect(command).toBe('xdg-open') + expect(args).toEqual(['http://127.0.0.1:5000']) + }) + test('logs the url instead of throwing when spawn fails', () => { + const logs: string[] = [] + openBrowser('http://127.0.0.1:5000', { + platform: 'linux', + spawn: () => { throw new Error('no display') }, + log: (m) => logs.push(m) + }) + expect(logs.some(l => l.includes('http://127.0.0.1:5000'))).toBe(true) + }) +}) diff --git a/packages/helpers/create-bsv-app/src/ui/__tests__/ui-page.test.ts b/packages/helpers/create-bsv-app/src/ui/__tests__/ui-page.test.ts new file mode 100644 index 000000000..9b8392bb1 --- /dev/null +++ b/packages/helpers/create-bsv-app/src/ui/__tests__/ui-page.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, test } from '@jest/globals' +import { serializeSchema, buildPage } from '../ui-page' +import type { ProjectManifest } from '../../config/project-manifest' + +describe('serializeSchema', () => { + test('fresh (new mode): capabilities options include wallet-login but NOT wallet-connect', () => { + const schema = serializeSchema(null) + const caps = schema.flatMap(s => s.fields).find(f => f.key === 'capabilities') + const values = caps?.options?.map(o => o.value) ?? [] + expect(values).toContain('wallet-login') + // wallet-connect is defaultSelected → excluded from new-mode picker + expect(values).not.toContain('wallet-connect') + }) + + test('existing with wallet-login already installed: it is filtered out of options', () => { + const m: ProjectManifest = { + version: 1, + name: 'demo', + network: 'test', + stack: { frontend: { framework: 'react', variant: 'react-ts' } }, + bsvDir: 'src/bsv', + capabilities: ['wallet-login'] + } + const schema = serializeSchema(m) + const caps = schema.flatMap(s => s.fields).find(f => f.key === 'capabilities') + expect(caps?.options?.map(o => o.value)).not.toContain('wallet-login') + }) + + test('add mode (existing without wallet-connect): wallet-connect IS offered', () => { + const m: ProjectManifest = { + version: 1, + name: 'demo', + network: 'test', + stack: { frontend: { framework: 'react', variant: 'react-ts' } }, + bsvDir: 'src/bsv', + capabilities: [] + } + const schema = serializeSchema(m) + const caps = schema.flatMap(s => s.fields).find(f => f.key === 'capabilities') + expect(caps?.options?.map(o => o.value)).toContain('wallet-connect') + }) + + test('when conditions survive serialization as plain objects', () => { + const schema = serializeSchema(null) + const variant = schema.flatMap(s => s.fields).find(f => f.key === 'frontendVariant') + expect(variant?.when).toEqual({ mode: 'new', frontend: 'react' }) + }) + + test('serializeSchema still excludes the defaultSelected base in new mode and carries ui/desc', () => { + const schema = serializeSchema(null) + const caps = schema.flatMap(s => s.fields).find(f => f.key === 'capabilities') + expect(caps?.options?.map(o => o.value)).not.toContain('wallet-connect') + expect(schema.find(s => s.id === 'mode')?.desc).toEqual(expect.any(String)) + expect(schema.flatMap(s => s.fields).find(f => f.key === 'frontend')?.ui).toBe('segmented') + // backend is its own segmented selector, independent of frontend (backend-only is selectable) + const backend = schema.flatMap(s => s.fields).find(f => f.key === 'backend') + expect(backend?.ui).toBe('segmented') + expect(backend?.when).toEqual({ mode: 'new' }) // not gated on frontend + }) +}) + +describe('buildPage', () => { + test('buildPage is self-contained (no external src/href) and inlines schema/seed', () => { + const html = buildPage({ schema: serializeSchema(null), seed: { mode: 'new' }, included: [{ label: 'wallet-connect' }] }) + expect(html).toContain('') + expect(html).toContain('window.__SCHEMA__') + expect(html).toContain('window.__SEED__') + expect(html).not.toMatch(/]+src=/) + expect(html).not.toMatch(/]+href=/) // external font dropped + expect(html).toContain('id="formWrap"') // new wizard DOM + expect(html).toContain('id="rail"') + expect(html).toContain('window.__INCLUDED__') + }) + + test('embeds capability labels and is self-contained (no external src/href)', () => { + const html = buildPage({ schema: serializeSchema(null), seed: { mode: 'new' } }) + expect(html).toContain('wallet-login') + expect(html).not.toMatch(/]+src=/) + expect(html).not.toMatch(/]+href=/) + }) + + test('renders "Always included" chips when included list is provided', () => { + const schema = serializeSchema(null) + const html = buildPage({ schema, seed: { mode: 'new' }, included: [{ label: 'Wallet connect' }] }) + expect(html).toContain('Always included') + expect(html).toContain('Wallet connect') + expect(html).toContain('window.__INCLUDED__') + }) + + test('no banner when included is empty or omitted — __INCLUDED__ still emitted as []', () => { + const schema = serializeSchema(null) + const htmlNoArg = buildPage({ schema, seed: { mode: 'new' } }) + const htmlEmpty = buildPage({ schema, seed: { mode: 'new' }, included: [] }) + // __INCLUDED__ always emitted for JS; old class="included" banner is gone + expect(htmlNoArg).toContain('window.__INCLUDED__') + expect(htmlEmpty).toContain('window.__INCLUDED__') + }) + + test('impact panel is captioned as BSV-files-only', () => { + const html = buildPage({ schema: serializeSchema(null), seed: { mode: 'new' }, included: [{ label: 'Wallet connect' }] }) + expect(html).toContain('scaffolded separately') + }) +}) diff --git a/packages/helpers/create-bsv-app/src/ui/__tests__/ui-server.test.ts b/packages/helpers/create-bsv-app/src/ui/__tests__/ui-server.test.ts new file mode 100644 index 000000000..e36869d49 --- /dev/null +++ b/packages/helpers/create-bsv-app/src/ui/__tests__/ui-server.test.ts @@ -0,0 +1,188 @@ +import { expect, test, beforeEach, afterEach } from '@jest/globals' +import { mkdtempSync, rmSync, existsSync, mkdirSync, writeFileSync, readFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { startUiServer, runUi } from '../ui-server' +import type { UiServer } from '../ui-server' +import type { RunCommand } from '../../scaffold/base-scaffolder' +import type { ProjectManifest } from '../../config/project-manifest' + +let dir: string +beforeEach(() => { dir = mkdtempSync(join(tmpdir(), 'cba-uisrv-')) }) +afterEach(() => { rmSync(dir, { recursive: true, force: true }) }) + +const noopRun: RunCommand = () => {} + +test('GET / serves the self-contained page', async () => { + const srv: UiServer = await startUiServer({ existing: null, targetDir: dir, deps: { runCommand: noopRun } }) + try { + const res = await fetch(srv.url) + expect(res.status).toBe(200) + const html = await res.text() + expect(html).toContain('create-bsv-app') + expect(html).toContain('window.__SCHEMA__') + } finally { srv.close() } +}) + +test('GET / in new mode includes "Always included" banner', async () => { + const srv: UiServer = await startUiServer({ existing: null, targetDir: dir, deps: { runCommand: noopRun } }) + try { + const res = await fetch(srv.url) + const html = await res.text() + expect(html).toContain('Always included') + } finally { srv.close() } +}) + +test('POST /generate (valid new draft) scaffolds, resolves done, and 200s', async () => { + const calls: string[][] = [] + const fake: RunCommand = (command, args) => { calls.push([command, ...args]) } + const target = join(dir, 'app') + const srv: UiServer = await startUiServer({ existing: null, targetDir: target, deps: { runCommand: fake } }) + const srvUrl: string = srv.url + const res = await fetch(`${srvUrl}/generate`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ mode: 'new', name: 'demo', frontend: 'react', capabilities: ['wallet-connect'] }) + }) + const data = await res.json() + expect(res.status).toBe(200) + expect(data.written).toContain('src/bsv/auth.ts') + expect(calls.some(c => c.includes('vite@latest'))).toBe(true) + expect(existsSync(join(target, 'bsv-scaffold.json'))).toBe(true) + const result = await srv.done + expect(result.targetDir).toBe(target) +}) + +// Item 5: new-mode POST /generate with wallet-login — confirms wallet-login file is written +test('POST /generate (new, wallet-login) scaffolds and includes useWalletLogin.tsx', async () => { + const calls: string[][] = [] + const fake: RunCommand = (command, args) => { calls.push([command, ...args]) } + const target = join(dir, 'app2') + const srv: UiServer = await startUiServer({ existing: null, targetDir: target, deps: { runCommand: fake } }) + const srvUrl: string = srv.url + const res = await fetch(`${srvUrl}/generate`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ mode: 'new', name: 'demo', frontend: 'react', capabilities: ['wallet-login'] }) + }) + const data = await res.json() + expect(res.status).toBe(200) + // wallet-login requires wallet-connect; new-mode expands, so auth.ts (wallet-connect) is placed + expect(data.written).toContain('src/bsv/auth.ts') + // wallet-login's own client file + expect(data.written).toContain('src/bsv/useWalletLogin.tsx') + expect(calls.some(c => c.includes('vite@latest'))).toBe(true) + expect(existsSync(join(target, 'bsv-scaffold.json'))).toBe(true) + const result = await srv.done + expect(result.targetDir).toBe(target) +}) + +test('POST /generate (invalid: new with no targets) returns 400 and stays up', async () => { + const srv: UiServer = await startUiServer({ existing: null, targetDir: dir, deps: { runCommand: noopRun } }) + const srvUrl: string = srv.url + try { + const res = await fetch(`${srvUrl}/generate`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ mode: 'new', name: 'demo', frontend: 'none', backend: 'none' }) + }) + expect(res.status).toBe(400) + const data = await res.json() + expect(String(data.error)).toMatch(/frontend or a backend/i) + expect((await fetch(srvUrl)).status).toBe(200) + } finally { srv.close() } +}) + +test('runUi opens the browser then resolves after the simulated submit', async () => { + const target = join(dir, 'app2') + const result = await runUi({ + existing: null, + targetDir: target, + runCommand: noopRun, + openBrowser: (url: string) => { + void fetch(`${url}/generate`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ mode: 'new', name: 'demo', frontend: 'react', capabilities: ['wallet-connect'] }) + }) + } + }) + expect(result.targetDir).toBe(target) + expect(result.written).toContain('src/bsv/auth.ts') +}) + +test('POST /plan returns the real BSV files create-bsv-app would write (new mode)', async () => { + const srv = await startUiServer({ existing: null, targetDir: dir, deps: { runCommand: noopRun } }) + try { + const srvUrl: string = srv.url + const res = await fetch(srvUrl + '/plan', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ mode: 'new', name: 'demo', frontend: 'react', capabilities: ['wallet-login'] }) + }) + expect(res.status).toBe(200) + const data = await res.json() + const paths = data.files.map((f: { path: string }) => f.path) + expect(paths).toContain('src/bsv/auth.ts') + expect(paths).toContain('src/bsv/WalletContext.tsx') + expect(paths).toContain('AGENTS.md') + expect(data.files.every((f: { status: string }) => f.status === 'new')).toBe(true) + } finally { srv.close() } +}) + +test('POST /plan marks an existing file as edit', async () => { + mkdirSync(join(dir, 'src', 'bsv'), { recursive: true }) + writeFileSync(join(dir, 'src', 'bsv', 'auth.ts'), '// existing', 'utf8') + const srv = await startUiServer({ existing: null, targetDir: dir, deps: { runCommand: noopRun } }) + try { + const srvUrl: string = srv.url + const res = await fetch(srvUrl + '/plan', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ mode: 'new', name: 'demo', frontend: 'react', capabilities: ['wallet-login'] }) + }) + const data = await res.json() + const auth = data.files.find((f: { path: string }) => f.path === 'src/bsv/auth.ts') + expect(auth.status).toBe('edit') + } finally { srv.close() } +}) + +test('POST /plan returns { files: [], error } for an invalid draft', async () => { + const srv = await startUiServer({ existing: null, targetDir: dir, deps: { runCommand: noopRun } }) + try { + const srvUrl: string = srv.url + const res = await fetch(srvUrl + '/plan', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ mode: 'new', name: 'demo', frontend: 'none', backend: 'none' }) + }) + expect(res.status).toBe(200) + const data = await res.json() + expect(data.files).toEqual([]) + expect(typeof data.error).toBe('string') + } finally { srv.close() } +}) + +test('POST /generate add-mode does NOT overwrite existing capability files (force=false)', async () => { + const existing: ProjectManifest = { + version: 1, + name: 'demo', + network: 'test', + stack: { frontend: { framework: 'react', variant: 'react-ts' } }, + bsvDir: 'src/bsv', + capabilities: [] + } + mkdirSync(join(dir, 'src', 'bsv'), { recursive: true }) + writeFileSync(join(dir, 'src', 'bsv', 'auth.ts'), '// SENTINEL', 'utf8') + const srv = await startUiServer({ existing, targetDir: dir, deps: { runCommand: noopRun } }) + try { + const srvUrl: string = srv.url + const res = await fetch(srvUrl + '/generate', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ capabilities: ['wallet-login'] }) + }) + expect(res.status).toBe(200) + expect(readFileSync(join(dir, 'src', 'bsv', 'auth.ts'), 'utf8')).toBe('// SENTINEL') + } finally { srv.close() } +}) diff --git a/packages/helpers/create-bsv-app/src/ui/open-browser.ts b/packages/helpers/create-bsv-app/src/ui/open-browser.ts new file mode 100644 index 000000000..270a34c23 --- /dev/null +++ b/packages/helpers/create-bsv-app/src/ui/open-browser.ts @@ -0,0 +1,29 @@ +import { spawn as nodeSpawn } from 'node:child_process' + +export type SpawnFn = (command: string, args: string[]) => void + +const defaultSpawn: SpawnFn = (command, args) => { + const child = nodeSpawn(command, args, { stdio: 'ignore', detached: true }) + child.unref() +} + +function launchFor (platform: NodeJS.Platform, url: string): { command: string, args: string[] } { + if (platform === 'win32') return { command: 'cmd', args: ['/c', 'start', '', url] } + if (platform === 'darwin') return { command: 'open', args: [url] } + return { command: 'xdg-open', args: [url] } +} + +export function openBrowser ( + url: string, + deps: { platform?: NodeJS.Platform, spawn?: SpawnFn, log?: (msg: string) => void } = {} +): void { + const platform = deps.platform ?? process.platform + const spawn = deps.spawn ?? defaultSpawn + const log = deps.log ?? ((m: string) => console.log(m)) + const { command, args } = launchFor(platform, url) + try { + spawn(command, args) + } catch { + log(`Open this URL in your browser:\n ${url}`) + } +} diff --git a/packages/helpers/create-bsv-app/src/ui/ui-page.ts b/packages/helpers/create-bsv-app/src/ui/ui-page.ts new file mode 100644 index 000000000..4e7175ef3 --- /dev/null +++ b/packages/helpers/create-bsv-app/src/ui/ui-page.ts @@ -0,0 +1,540 @@ +import { configSchema, type ConfigSchema } from '../config/schema.js' +import { listCapabilities } from '../registry.js' +import { remainingCapabilityIds, type ProjectManifest } from '../config/project-manifest.js' +export function serializeSchema (existing: ProjectManifest | null): ConfigSchema { + const allIds = listCapabilities().map(c => c.id) + const defaultIds = listCapabilities().filter(c => c.defaultSelected === true).map(c => c.id) + const offerable = existing !== null + ? remainingCapabilityIds(existing, allIds) + : allIds.filter(id => !defaultIds.includes(id)) + return configSchema.map(section => ({ + ...section, + fields: section.fields.map(field => { + if (field.key !== 'capabilities') return { ...field } + const options = (field.options ?? []).filter(o => offerable.includes(o.value)) + return { ...field, options } + }) + })) +} + +const STYLES = `:root { --accent: #2196F3; } +* { box-sizing: border-box; } +html, body { margin: 0; height: 100%; } +body { background: #0b0e13; color: #cdd4de; font: 14px/1.5 system-ui, -apple-system, sans-serif; } + +.app { display: flex; height: 100vh; min-height: 600px; overflow: hidden; position: relative; } + +/* ---- sidebar ---- */ +.side { width: 244px; flex: 0 0 auto; background: #090c11; border-right: 1px solid #1b222b; padding: 20px 14px 16px; display: flex; flex-direction: column; } +.brand { display: flex; align-items: center; gap: 10px; padding: 0 8px 20px; } +.brand svg, .brand .logo { width: 24px; height: 24px; display: block; } +.brand span { font-weight: 600; font-size: 15px; color: #e8edf4; } +#nav { display: flex; flex-direction: column; gap: 4px; } +.nav-item { display: flex; align-items: center; gap: 11px; width: 100%; text-align: left; padding: 9px 10px; border-radius: 7px; border: 0; background: transparent; color: #7b8694; font: 500 13px/1 system-ui; cursor: pointer; } +.nav-item .ic { width: 19px; height: 19px; border-radius: 5px; border: 1px solid #2c3540; color: #6b7480; font: 600 11px/17px system-ui; text-align: center; flex: 0 0 auto; } +.nav-item.done { background: #11202e; color: #cfe0ee; } +.nav-item.done .ic { background: var(--accent); border-color: var(--accent); color: #06121f; line-height: 19px; } +.nav-item.active { border: 1px solid var(--accent); background: rgba(33,150,243,.08); color: #fff; font-weight: 600; } +.nav-item.active .ic { border-color: var(--accent); color: var(--accent); } +.prog { margin-top: auto; padding: 0 8px; } +.prog-bar { height: 4px; border-radius: 2px; background: #1a222b; overflow: hidden; margin-bottom: 9px; } +#progFill { height: 100%; background: var(--accent); border-radius: 2px; transition: width .2s; } +#progLabel { font: 400 11px/1.4 system-ui; color: #4a525d; } + +/* ---- form ---- */ +.main { flex: 1; min-width: 0; overflow-y: auto; padding: 38px 44px; } +.main-inner { max-width: 540px; } +.sec-title { font: 600 22px/1.1 system-ui; color: #e8edf4; margin-bottom: 5px; } +.sec-desc { font: 400 13px/1.5 system-ui; color: #7b8694; margin-bottom: 30px; } +.field { margin-bottom: 24px; } +.field-label { display: block; font: 500 12px/1 system-ui; color: #aab3bf; margin-bottom: 9px; } +.input { width: 100%; height: 40px; padding: 0 13px; border: 1px solid #2c3540; border-radius: 7px; background: #0f151c; color: #cdd4de; font: 400 13px/1 "JetBrains Mono", ui-monospace, monospace; outline: none; } +.input:focus { border-color: var(--accent); } +.select { width: 100%; height: 40px; padding: 0 11px; border: 1px solid #2c3540; border-radius: 7px; background: #0f151c; color: #cdd4de; color-scheme: dark; font: 400 13px/1 system-ui; outline: none; cursor: pointer; } +.select:focus { border-color: var(--accent); } +.seg { display: flex; gap: 7px; } +.seg-btn { flex: 1; height: 40px; border-radius: 7px; border: 1px solid #2c3540; background: transparent; color: #9aa3af; font: 500 12px/1 system-ui; cursor: pointer; } +.seg-btn:hover { border-color: #3d4855; color: #cdd4de; } +.seg-btn.on { border-color: var(--accent); background: var(--accent); color: #06121f; font-weight: 600; } +.toggle-row { display: flex; align-items: center; gap: 13px; } +.switch { width: 46px; height: 26px; border-radius: 13px; border: 0; background: #2c3540; position: relative; cursor: pointer; flex: 0 0 auto; } +.switch .knob { position: absolute; top: 3px; left: 3px; width: 20px; height: 20px; border-radius: 50%; background: #5a636e; transition: .15s; } +.switch.on { background: var(--accent); } +.switch.on .knob { left: 23px; background: #06121f; } +.toggle-state { font: 400 12px/1 system-ui; color: #7b8694; } +.opt-card { display: flex; gap: 11px; align-items: flex-start; width: 100%; text-align: left; border: 1px solid #243441; background: #0f151c; border-radius: 9px; padding: 13px; cursor: pointer; margin-bottom: 8px; } +.opt-card .box { width: 19px; height: 19px; border-radius: 5px; border: 1px solid #2c3540; flex: 0 0 auto; } +.opt-card.on .box { background: var(--accent); border-color: var(--accent); color: #06121f; font: 600 11px/19px system-ui; text-align: center; } +.opt-card .ot { font: 500 13px/1.3 system-ui; color: #cdd4de; } +.opt-card .oh { display: block; font: 400 11px/1.45 system-ui; color: #6b7480; margin-top: 4px; } + +/* ---- command rail ---- */ +.rail { width: 420px; flex: 0 0 auto; background: #070a0e; border-left: 1px solid #1b222b; padding: 26px; display: flex; flex-direction: column; overflow-y: auto; } +.label { font: 600 10px/1 system-ui; letter-spacing: .14em; color: #6b7480; text-transform: uppercase; margin-bottom: 11px; } +.term { background: #05070a; border: 1px solid #243441; border-radius: 8px; padding: 14px; font: 400 12px/1.75 "JetBrains Mono", ui-monospace, monospace; white-space: pre-wrap; word-break: break-word; margin-bottom: 18px; } +.term .prompt { color: var(--accent); } +.chips { display: flex; gap: 7px; flex-wrap: wrap; margin-bottom: 18px; } +.chip { font: 400 11px/1 system-ui; color: #9fb4c9; background: #11202e; border: 1px solid #1c3346; border-radius: 20px; padding: 6px 10px; } +.impact { border-top: 1px solid #161d25; padding-top: 15px; } +.impact-head { width: 100%; display: flex; align-items: center; justify-content: space-between; background: transparent; border: 0; padding: 3px 0; cursor: pointer; } +.impact-head .ht { display: flex; align-items: center; gap: 8px; font: 600 10px/1 system-ui; letter-spacing: .14em; color: #9aa6b2; text-transform: uppercase; } +.impact-head .chev { font-size: 9px; color: #6b7480; } +.impact-head.open .chev { color: var(--accent); } +.impact-head .cnt { font: 500 10px/1 system-ui; color: #5a636e; } +.impact-head .cnt b { color: #7fd6a0; font-weight: 500; } +.impact-body { margin-top: 13px; } +.impact-note { font: 400 11px/1.5 system-ui; color: #6b7480; margin-bottom: 11px; } +.view-toggle { display: flex; justify-content: flex-end; margin-bottom: 11px; } +.vt { display: flex; border: 1px solid #2c3540; border-radius: 6px; overflow: hidden; } +.vt button { background: transparent; color: #7b8694; font: 500 10px/1 system-ui; padding: 6px 11px; border: 0; cursor: pointer; } +.vt button + button { border-left: 1px solid #2c3540; } +.vt button.on { background: var(--accent); color: #06121f; font-weight: 600; } +.tree { background: #05070a; border: 1px solid #1d242d; border-radius: 7px; padding: 13px; } +.tree-line { white-space: pre; font: 400 11px/1.85 "JetBrains Mono", ui-monospace, monospace; } +.tree-line .pfx { color: #566373; } +.flist { background: #05070a; border: 1px solid #1d242d; border-radius: 7px; padding: 11px 13px; display: flex; flex-direction: column; gap: 4px; } +.frow { display: flex; align-items: center; justify-content: space-between; gap: 10px; } +.frow .fp { font: 400 11px/1.9 "JetBrains Mono", ui-monospace, monospace; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.badge { font: 600 9px/1 system-ui; border-radius: 4px; padding: 3px 5px; flex: 0 0 auto; } +.badge.new { color: #7fd6a0; background: #0f2418; border: 1px solid #1d3d28; } +.badge.edit { color: #e0b25a; background: #241d0f; border: 1px solid #3d3219; } +.actions { margin-top: auto; padding-top: 18px; display: flex; flex-direction: column; gap: 9px; } +.err { color: #e06a5a; font: 400 12px/1.4 system-ui; min-height: 16px; } +.btn { height: 40px; border: 1px solid #2c3540; border-radius: 7px; background: transparent; color: #aab3bf; font: 500 13px/1 system-ui; cursor: pointer; } +.btn:hover { border-color: #3d4855; color: #cdd4de; } +.btn-primary { height: 44px; border: 0; border-radius: 7px; background: var(--accent); color: #06121f; font: 600 14px/1 system-ui; cursor: pointer; } + +/* ---- done overlay ---- */ +.overlay { position: fixed; inset: 0; background: rgba(4,6,9,.88); display: flex; align-items: center; justify-content: center; z-index: 50; } +.overlay .card { background: #0e141b; border: 1px solid #243441; border-radius: 14px; padding: 36px 40px; max-width: 390px; text-align: center; box-shadow: 0 24px 70px rgba(0,0,0,.55); } +.overlay .ok { width: 54px; height: 54px; border-radius: 50%; background: var(--accent); color: #06121f; font: 600 27px/54px system-ui; margin: 0 auto 18px; } +.overlay h2 { font: 600 19px/1.2 system-ui; color: #e8edf4; margin: 0 0 9px; } +.overlay p { font: 400 13px/1.6 system-ui; color: #8b95a0; margin: 0 0 24px; } +` + +const LOGO_SVG = '' + +const CLIENT_SCRIPT = `/* create-bsv-app --ui : schema-driven static page (no dependencies). + * Reads window.__SCHEMA__ / __SEED__ / __INCLUDED__ and POSTs the draft to /generate. + * Optional globals: __ACCENT__ (hex), __CMD_LABEL__ (string), __DEMO__ (bool, skips server). */ +(function () { + var SCHEMA = window.__SCHEMA__ || []; + var SEED = window.__SEED__ || {}; + var INCLUDED = window.__INCLUDED__ || [{ label: '@bsv/sdk' }, { label: 'AGENTS.md' }]; + var ACCENT = window.__ACCENT__ || '#2196F3'; + var CMD_LABEL = window.__CMD_LABEL__ || 'Your command'; + + document.documentElement.style.setProperty('--accent', ACCENT); + + /* RECONCILIATION 2: active uses s.id, falls back to first visible section id */ + function visibleSections() { + return SCHEMA.filter(function (s) { + return (s.fields || []).some(function (f) { return whenOk(f.when); }); + }); + } + + var state = { + active: (SCHEMA[0] || {}).id, + impactOpen: true, + impactView: 'tree', + copied: false, + generating: false, + error: '', + ov: null, + plan: [] + }; + + var draft = {}; + for (var si = 0; si < SCHEMA.length; si++) { + var fs = SCHEMA[si].fields || []; + for (var fi = 0; fi < fs.length; fi++) { + var f = fs[fi]; + if (SEED[f.key] !== undefined) draft[f.key] = SEED[f.key]; + else if (f.type === 'multiselect') draft[f.key] = Array.isArray(f.default) ? f.default.slice() : []; + else if (f.default !== undefined) draft[f.key] = f.default; + } + } + + function el(tag, attrs, kids) { + var n = document.createElement(tag); + attrs = attrs || {}; + for (var k in attrs) { + var v = attrs[k]; + if (v == null || v === false) continue; + if (k === 'text') n.textContent = v; + else if (k === 'html') n.innerHTML = v; + else if (k === 'class') n.className = v; + else if (k.slice(0, 2) === 'on' && typeof v === 'function') n[k.toLowerCase()] = v; + else n.setAttribute(k, v); + } + (kids || []).forEach(function (c) { + if (c == null) return; + n.appendChild(typeof c === 'string' ? document.createTextNode(c) : c); + }); + return n; + } + + function whenOk(when) { + if (!when) return true; + return Object.keys(when).every(function (k) { return draft[k] === when[k]; }); + } + + /* ---- command ---- */ + function buildCommand(d) { + var p = ['npx create-bsv-app', '--mode', d.mode || 'new']; + if ((d.mode || 'new') === 'new') { + if (d.name) p.push('--name', JSON.stringify(d.name)); + if (d.frontend && d.frontend !== 'none') p.push('--frontend', d.frontend); + if (d.frontend === 'react' && d.frontendVariant) p.push('--variant', d.frontendVariant); + if (d.backend && d.backend !== 'none') p.push('--backend', d.backend); + if (d.bsvDir) p.push('--bsv-dir', d.bsvDir); + if (d.packageManager) p.push('--package-manager', d.packageManager); + if (d.network) p.push('--network', d.network); + /* RECONCILIATION 4: glue defaults on — emit --no-glue only when explicitly false in new mode */ + if ((d.mode || 'new') === 'new' && d.glue === false) p.push('--no-glue'); + } + if (d.capabilities && d.capabilities.length) p.push('--capabilities', d.capabilities.join(',')); + p.push('--yes'); + return p.join(' '); + } + + function buildTokens(d) { + var FLAG = '#7fd6a0', VAL = '#c8d0da', STR = '#e0b25a'; + var t = [{ t: 'npx create-bsv-app', c: VAL }]; + function flag(f, v, col) { t.push({ t: ' ' + f + ' ', c: FLAG }); if (v !== undefined) t.push({ t: v, c: col || VAL }); } + flag('--mode', d.mode || 'new'); + if ((d.mode || 'new') === 'new') { + if (d.name) flag('--name', '"' + d.name + '"', STR); + if (d.frontend && d.frontend !== 'none') flag('--frontend', d.frontend); + if (d.frontend === 'react' && d.frontendVariant) flag('--variant', d.frontendVariant); + if (d.backend && d.backend !== 'none') flag('--backend', d.backend); + if (d.bsvDir) flag('--bsv-dir', d.bsvDir); + if (d.packageManager) flag('--package-manager', d.packageManager); + if (d.network) flag('--network', d.network); + /* RECONCILIATION 4: glue defaults on — emit --no-glue only when explicitly false in new mode */ + if ((d.mode || 'new') === 'new' && d.glue === false) flag('--no-glue'); + } + if (d.capabilities && d.capabilities.length) flag('--capabilities', d.capabilities.join(',')); + flag('--yes'); + return t; + } + + /* RECONCILIATION 3: real impact via /plan — computeFiles() deleted */ + var planTimer = null; + function fetchPlan() { + if (window.__DEMO__) { state.plan = []; return; } + clearTimeout(planTimer); + planTimer = setTimeout(function () { + fetch('/plan', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(draft) }) + .then(function (r) { return r.json(); }) + .then(function (d) { state.plan = (d && d.files) || []; renderRail(); }) + .catch(function () { state.plan = []; renderRail(); }); + }, 150); + } + + function buildTree(files, projectName) { + var root = {}; + files.forEach(function (file) { + var parts = file.path.split('/'); + var cur = root; + parts.forEach(function (p, i) { + /* RECONCILIATION 3: read file.status (not file.st) */ + if (i === parts.length - 1) cur[p] = { __file: true, status: file.status }; + else { if (!cur[p] || cur[p].__file) cur[p] = {}; cur = cur[p]; } + }); + }); + var lines = [{ prefix: '', name: projectName + '/', color: '#cfe0ee' }]; + (function walk(node, prefix) { + var keys = Object.keys(node).sort(function (a, b) { + var ad = !node[a].__file, bd = !node[b].__file; + if (ad !== bd) return ad ? -1 : 1; + return a < b ? -1 : a > b ? 1 : 0; + }); + keys.forEach(function (k, i) { + var last = i === keys.length - 1; + var child = node[k]; + var isFile = !!child.__file; + var conn = last ? '\\u2514\\u2500 ' : '\\u251c\\u2500 '; + /* RECONCILIATION 3: read child.status (not child.st) */ + var color = isFile ? (child.status === 'edit' ? '#e0b25a' : '#7fd6a0') : '#cfe0ee'; + lines.push({ prefix: prefix + conn, name: isFile ? k : k + '/', color: color }); + if (!isFile) walk(child, prefix + (last ? ' ' : '\\u2502 ')); + }); + })(root, ''); + return lines; + } + + /* ---- field controls ---- */ + function fieldControl(f) { + if (f.type === 'text') { + var i = el('input', { class: 'input', type: 'text', value: draft[f.key] || '' }); + i.oninput = function () { draft[f.key] = i.value; renderRail(); fetchPlan(); }; + return i; + } + if (f.ui === 'segmented') { + var seg = el('div', { class: 'seg' }); + (f.options || []).forEach(function (o) { + var b = el('button', { class: 'seg-btn' + (draft[f.key] === o.value ? ' on' : ''), text: o.label }); + b.onclick = function () { draft[f.key] = o.value; renderForm(); renderRail(); fetchPlan(); }; + seg.appendChild(b); + }); + return seg; + } + if (f.type === 'select') { + var s = el('select', { class: 'select' }); + (f.options || []).forEach(function (o) { + var opt = el('option', { value: o.value, text: o.label }); + if (draft[f.key] === o.value) opt.selected = true; + s.appendChild(opt); + }); + if (draft[f.key] === undefined && f.options && f.options.length) draft[f.key] = f.options[0].value; + s.onchange = function () { draft[f.key] = s.value; renderForm(); renderRail(); fetchPlan(); }; + return s; + } + if (f.type === 'toggle') { + var row = el('div', { class: 'toggle-row' }); + var sw = el('button', { class: 'switch' + (draft[f.key] ? ' on' : '') }, [el('span', { class: 'knob' })]); + sw.onclick = function () { draft[f.key] = !draft[f.key]; renderForm(); renderRail(); fetchPlan(); }; + row.appendChild(sw); + row.appendChild(el('span', { class: 'toggle-state', text: draft[f.key] ? 'Enabled' : 'Disabled' })); + return row; + } + // multiselect + var box = el('div'); + (f.options || []).forEach(function (o) { + var on = (draft[f.key] || []).indexOf(o.value) !== -1; + var txt = el('span', {}, [el('span', { class: 'ot', text: o.label })]); + if (o.hint) txt.appendChild(el('span', { class: 'oh', text: o.hint })); + var card = el('button', { class: 'opt-card' + (on ? ' on' : '') }, [el('span', { class: 'box', text: on ? '\\u2713' : '' }), txt]); + card.onclick = function () { + var set = {}; + (draft[f.key] || []).forEach(function (v) { set[v] = true; }); + if (set[o.value]) delete set[o.value]; else set[o.value] = true; + draft[f.key] = Object.keys(set); + renderForm(); renderRail(); fetchPlan(); + }; + box.appendChild(card); + }); + return box; + } + + /* ---- renderers ---- */ + /* RECONCILIATION 1+2: use s.id; RECONCILIATION 2: use visibleSections() */ + function activeIndex() { + var vs = visibleSections(); + var idx = vs.map(function (s) { return s.id; }).indexOf(state.active); + return Math.max(0, idx); + } + + function renderNav() { + var nav = document.getElementById('nav'); + nav.innerHTML = ''; + var vs = visibleSections(); + var ai = activeIndex(); + /* RECONCILIATION 2: ensure state.active is always a visible section */ + if (ai === 0 && vs.length > 0 && vs[0].id !== state.active) { + state.active = vs[0].id; + } + vs.forEach(function (s, i) { + var cls = i < ai ? 'done' : (i === ai ? 'active' : 'todo'); + /* RECONCILIATION 1: use s.id for navigation (was s.key) */ + var b = el('button', { class: 'nav-item ' + cls }, [el('span', { class: 'ic', text: i < ai ? '\\u2713' : String(i + 1) }), s.title]); + b.onclick = function () { state.active = s.id; renderNav(); renderForm(); renderProgress(); }; + nav.appendChild(b); + }); + } + + function renderProgress() { + /* RECONCILIATION 2: use visibleSections() */ + var vs = visibleSections(); + var ai = activeIndex(), total = vs.length || 1; + document.getElementById('progFill').style.width = Math.round(((ai + 1) / total) * 100) + '%'; + document.getElementById('progLabel').textContent = 'Step ' + (ai + 1) + ' of ' + total; + } + + function renderForm() { + var wrap = document.getElementById('formWrap'); + wrap.innerHTML = ''; + /* RECONCILIATION 2: use visibleSections(); fall back active to first visible */ + var vs = visibleSections(); + if (vs.length === 0) return; + var sec = vs[activeIndex()] || vs[0]; + /* RECONCILIATION 2: if active section is no longer visible, snap to first */ + if (!vs.some(function (s) { return s.id === state.active; })) { + state.active = vs[0].id; + sec = vs[0]; + } + if (!sec) return; + wrap.appendChild(el('div', { class: 'sec-title', text: sec.title })); + /* RECONCILIATION 1: section description via s.desc */ + wrap.appendChild(el('div', { class: 'sec-desc', text: sec.desc || '' })); + sec.fields.filter(function (f) { return whenOk(f.when); }).forEach(function (f) { + var field = el('div', { class: 'field' }, [el('label', { class: 'field-label', text: f.label })]); + field.appendChild(fieldControl(f)); + wrap.appendChild(field); + }); + } + + function renderRail() { + var rail = document.getElementById('rail'); + rail.innerHTML = ''; + rail.appendChild(el('div', { class: 'label', text: CMD_LABEL })); + + var term = el('div', { class: 'term' }, [el('span', { class: 'prompt', text: '$ ' })]); + buildTokens(draft).forEach(function (tk) { var s = el('span', { text: tk.t }); s.style.color = tk.c; term.appendChild(s); }); + rail.appendChild(term); + + if (INCLUDED.length) { + rail.appendChild(el('div', { class: 'label', text: 'Always included' })); + var chips = el('div', { class: 'chips' }); + INCLUDED.forEach(function (c) { chips.appendChild(el('span', { class: 'chip', text: c.label })); }); + rail.appendChild(chips); + } + + /* RECONCILIATION 3: use state.plan (from /plan) instead of computeFiles() */ + var files = state.plan; + var newCount = files.filter(function (f) { return f.status === 'new'; }).length; + var impact = el('div', { class: 'impact' }); + var head = el('button', { class: 'impact-head' + (state.impactOpen ? ' open' : '') }); + head.onclick = function () { state.impactOpen = !state.impactOpen; renderRail(); }; + var ht = el('span', { class: 'ht' }, [el('span', { class: 'chev', text: state.impactOpen ? '\\u25be' : '\\u25b8' }), 'Project impact']); + var cnt = el('span', { class: 'cnt' }); + cnt.appendChild(document.createTextNode(files.length + ' \\u00b7 ')); + cnt.appendChild(el('b', { text: newCount + ' new' })); + head.appendChild(ht); head.appendChild(cnt); + impact.appendChild(head); + + if (state.impactOpen) { + var body = el('div', { class: 'impact-body' }); + body.appendChild(el('div', { class: 'impact-note', text: 'BSV files create-bsv-app writes — your framework files (Vite/Express) are scaffolded separately.' })); + var vtg = el('div', { class: 'vt' }); + var tb = el('button', { class: state.impactView === 'tree' ? 'on' : '', text: 'Tree' }); + tb.onclick = function () { state.impactView = 'tree'; renderRail(); }; + var lb = el('button', { class: state.impactView === 'list' ? 'on' : '', text: 'List' }); + lb.onclick = function () { state.impactView = 'list'; renderRail(); }; + vtg.appendChild(tb); vtg.appendChild(lb); + body.appendChild(el('div', { class: 'view-toggle' }, [vtg])); + + if (state.impactView === 'tree') { + var tree = el('div', { class: 'tree' }); + buildTree(files, draft.name || 'project').forEach(function (ln) { + var line = el('div', { class: 'tree-line' }, [el('span', { class: 'pfx', text: ln.prefix })]); + var nm = el('span', { text: ln.name }); nm.style.color = ln.color; line.appendChild(nm); + tree.appendChild(line); + }); + body.appendChild(tree); + } else { + var fl = el('div', { class: 'flist' }); + /* RECONCILIATION 3: read f.status (not f.st) */ + files.forEach(function (f) { + var fp = el('span', { class: 'fp', text: f.path }); fp.style.color = f.status === 'edit' ? '#8b95a0' : '#c2cad4'; + var badge = el('span', { class: 'badge ' + (f.status === 'edit' ? 'edit' : 'new'), text: f.status === 'edit' ? 'EDIT' : 'NEW' }); + fl.appendChild(el('div', { class: 'frow' }, [fp, badge])); + }); + body.appendChild(fl); + } + impact.appendChild(body); + } + rail.appendChild(impact); + + var actions = el('div', { class: 'actions' }); + if (state.error) actions.appendChild(el('div', { class: 'err', text: state.error })); + var copy = el('button', { class: 'btn', text: state.copied ? 'Copied!' : 'Copy command' }); + copy.onclick = copyCmd; + var gen = el('button', { class: 'btn-primary', text: state.generating ? 'Generating\\u2026' : 'Generate' }); + gen.onclick = generate; + actions.appendChild(copy); actions.appendChild(gen); + rail.appendChild(actions); + } + + function copyCmd() { + try { navigator.clipboard && navigator.clipboard.writeText(buildCommand(draft)); } catch (e) {} + state.copied = true; renderRail(); + clearTimeout(copyCmd._t); + copyCmd._t = setTimeout(function () { state.copied = false; renderRail(); }, 1500); + } + + function generate() { + state.error = ''; + if (window.__DEMO__) { + showOverlay(state.plan.map(function (f) { return f.path; }), './' + (draft.name || 'project')); + return; + } + state.generating = true; renderRail(); + fetch('/generate', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(draft) }) + .then(function (r) { return r.json().then(function (data) { return { ok: r.ok, data: data }; }); }) + .then(function (res) { + state.generating = false; + if (!res.ok) { state.error = (res.data && res.data.error) || 'Failed'; renderRail(); return; } + showOverlay(res.data.written || [], res.data.targetDir || '.'); + }) + .catch(function (e) { state.generating = false; state.error = String(e); renderRail(); }); + } + + function showOverlay(written, dir) { + hideOverlay(); + var host = document.getElementById('overlayHost') || document.body; + var card = el('div', { class: 'card' }, [ + el('div', { class: 'ok', text: '\\u2713' }), + el('h2', { text: 'Project generated' }), + el('p', { text: 'Wrote ' + written.length + ' file(s) to ' + dir + '. See AGENTS.md for wiring \\u2014 you can close this tab.' }) + ]); + var btn = el('button', { class: 'btn', text: 'Start over' }); + btn.onclick = hideOverlay; + card.appendChild(btn); + var ov = el('div', { class: 'overlay' }, [card]); + host.appendChild(ov); + state.ov = ov; + } + function hideOverlay() { + if (state.ov && state.ov.parentNode) state.ov.parentNode.removeChild(state.ov); + state.ov = null; + } + + renderNav(); + renderForm(); + renderRail(); + renderProgress(); + /* RECONCILIATION 3: initial plan fetch */ + fetchPlan(); +})(); +` + +export function buildPage (opts: { + schema: unknown + seed: unknown + included?: Array<{ label: string }> + accent?: string + commandLabel?: string +}): string { + const data = + 'window.__SCHEMA__ = ' + JSON.stringify(opts.schema) + ';\n' + + 'window.__SEED__ = ' + JSON.stringify(opts.seed) + ';\n' + + 'window.__INCLUDED__ = ' + JSON.stringify(opts.included ?? []) + ';\n' + + (opts.accent != null ? 'window.__ACCENT__ = ' + JSON.stringify(opts.accent) + ';\n' : '') + + (opts.commandLabel != null ? 'window.__CMD_LABEL__ = ' + JSON.stringify(opts.commandLabel) + ';\n' : '') + + return ` + + + + +create-bsv-app + + + +
+ +
+ +
+
+ + + +` +} diff --git a/packages/helpers/create-bsv-app/src/ui/ui-server.ts b/packages/helpers/create-bsv-app/src/ui/ui-server.ts new file mode 100644 index 000000000..5144035a2 --- /dev/null +++ b/packages/helpers/create-bsv-app/src/ui/ui-server.ts @@ -0,0 +1,126 @@ +import { createServer, type IncomingMessage, type ServerResponse } from 'node:http' +import type { AddressInfo } from 'node:net' +import { existsSync } from 'node:fs' +import { join } from 'node:path' +import { serializeSchema, buildPage } from './ui-page.js' +import { openBrowser as defaultOpenBrowser } from './open-browser.js' +import { applyConfig, type RunResult } from '../pipeline.js' +import { resolveDraft, seedDraft, type ConfigDraft } from '../config/draft.js' +import { ConfigError } from '../config/validate.js' +import type { ProjectManifest } from '../config/project-manifest.js' +import { MANIFEST_FILE } from '../config/project-manifest.js' +import type { RunCommand } from '../scaffold/base-scaffolder.js' +import { listCapabilities, resolveCapabilities } from '../registry.js' +import { planPlacement } from '../engine.js' +import { layoutOf } from '../config/model.js' + +export interface UiServer { url: string, done: Promise, close: () => void } + +async function readBody (req: IncomingMessage): Promise { + const chunks: Buffer[] = [] + for await (const c of req) chunks.push(c as Buffer) + return Buffer.concat(chunks).toString('utf8') +} + +function sendJson (res: ServerResponse, status: number, body: unknown): void { + res.writeHead(status, { 'content-type': 'application/json' }) + res.end(JSON.stringify(body)) +} + +export async function startUiServer ( + opts: { existing: ProjectManifest | null, targetDir: string, deps?: { runCommand?: RunCommand } } +): Promise { + const { existing, targetDir } = opts + const included = existing === null + ? listCapabilities().filter(c => c.defaultSelected === true).map(c => ({ label: c.title })) + : [] + const html = buildPage({ schema: serializeSchema(existing), seed: seedDraft(existing, {}), included }) + + let resolveDone: (r: RunResult) => void = () => {} + const done = new Promise((resolve) => { resolveDone = resolve }) + + const server = createServer((req, res) => { + void (async () => { + if (req.method === 'GET' && (req.url === '/' || req.url === '')) { + res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }) + res.end(html) + return + } + if (req.method === 'POST' && req.url === '/generate') { + try { + const draft = JSON.parse(await readBody(req)) as ConfigDraft + const config = resolveDraft(seedDraft(existing, draft)) + // force:false — preserve existing capability files, matching the CLI default (the user re-runs with intent but we never clobber their edits) + const result = applyConfig(config, targetDir, { + runCommand: opts.deps?.runCommand, + force: false + }) + sendJson(res, 200, { targetDir: result.targetDir, written: result.written, deps: result.deps }) + resolveDone(result) + return + } catch (err) { + const status = err instanceof ConfigError ? 400 : 500 + sendJson(res, status, { error: err instanceof Error ? err.message : String(err) }) + return + } + } + if (req.method === 'POST' && req.url === '/plan') { + try { + const draft = JSON.parse(await readBody(req)) as ConfigDraft + const config = resolveDraft(seedDraft(existing, draft)) + const caps = resolveCapabilities(config.capabilities, { expandRequires: config.mode === 'new' }) + const placement = planPlacement(config, caps) + const rawPaths: string[] = [...placement.utilFiles, ...placement.glueFiles].map(f => f.path) + rawPaths.push('AGENTS.md', MANIFEST_FILE) + if (config.mode === 'new' && config.glue) { + const layout = layoutOf(config.stack) + if (layout === 'frontend-only' || layout === 'monorepo') { + const cp = layout === 'monorepo' ? 'client/' : '' + rawPaths.push(cp + 'src/main.tsx', cp + 'src/App.tsx') + } + if (layout === 'monorepo' || layout === 'backend-only') { + const sp = layout === 'monorepo' ? 'server/' : '' + rawPaths.push(sp + 'src/index.ts') + } + } + const seen = new Set() + const dedupedPaths: string[] = [] + for (const p of rawPaths) { + if (!seen.has(p)) { seen.add(p); dedupedPaths.push(p) } + } + const files = dedupedPaths.map(p => ({ path: p, status: existsSync(join(targetDir, p)) ? 'edit' as const : 'new' as const })) + sendJson(res, 200, { files }) + return + } catch (err) { + sendJson(res, 200, { files: [], error: err instanceof Error ? err.message : String(err) }) + return + } + } + sendJson(res, 404, { error: 'not found' }) + })() + }) + + await new Promise((resolve) => { server.listen(0, '127.0.0.1', resolve) }) + const { port } = server.address() as AddressInfo + const url = `http://127.0.0.1:${port}` + return { url, done, close: () => server.close() } +} + +export interface RunUiOpts { + existing: ProjectManifest | null + targetDir: string + runCommand?: RunCommand + openBrowser?: (url: string) => void +} + +export async function runUi (opts: RunUiOpts): Promise { + const srv = await startUiServer({ existing: opts.existing, targetDir: opts.targetDir, deps: { runCommand: opts.runCommand } }) + const open = opts.openBrowser ?? ((url: string) => defaultOpenBrowser(url)) + console.log(`\ncreate-bsv-app UI: ${srv.url}\nFill the form and press Generate (or Ctrl-C to cancel).`) + open(srv.url) + try { + return await srv.done + } finally { + srv.close() + } +} diff --git a/packages/helpers/create-bsv-app/tsconfig.eslint.json b/packages/helpers/create-bsv-app/tsconfig.eslint.json new file mode 100644 index 000000000..930689284 --- /dev/null +++ b/packages/helpers/create-bsv-app/tsconfig.eslint.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": true + }, + "include": [ + "./src/**/*.ts" + ], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/helpers/create-bsv-app/tsconfig.json b/packages/helpers/create-bsv-app/tsconfig.json new file mode 100644 index 000000000..57bb4c348 --- /dev/null +++ b/packages/helpers/create-bsv-app/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "esModuleInterop": true, + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "declaration": true, + "sourceMap": true, + "types": ["node"] + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "dist", "**/*.test.ts", "src/__tests__/**"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 486afc6f7..4fd3b324b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -195,6 +195,34 @@ importers: specifier: ^6.0.3 version: 6.0.3 + packages/helpers/create-bsv-app: + dependencies: + '@clack/prompts': + specifier: ^0.7.0 + version: 0.7.0 + devDependencies: + '@jest/globals': + specifier: ^30.4.1 + version: 30.4.1 + '@types/jest': + specifier: ^30.0.0 + version: 30.0.0 + '@types/node': + specifier: ^26.0.0 + version: 26.0.0 + jest: + specifier: ^30.4.2 + version: 30.4.2(@types/node@26.0.0)(babel-plugin-macros@3.1.0)(node-notifier@8.0.2)(ts-node@10.9.2(@types/node@26.0.0)(typescript@6.0.3)) + ts-jest: + specifier: ^29.4.11 + version: 29.4.11(@babel/core@7.29.7)(@jest/transform@30.4.1)(@jest/types@30.4.1)(babel-jest@30.4.1(@babel/core@7.29.7))(esbuild@0.27.7)(jest-util@30.4.1)(jest@30.4.2(@types/node@26.0.0)(babel-plugin-macros@3.1.0)(node-notifier@8.0.2)(ts-node@10.9.2(@types/node@26.0.0)(typescript@6.0.3)))(typescript@6.0.3) + ts-standard: + specifier: ^12.0.2 + version: 12.0.2(typescript@6.0.3) + typescript: + specifier: ^6.0.3 + version: 6.0.3 + packages/helpers/did: dependencies: qrcode: @@ -2329,6 +2357,14 @@ packages: '@chevrotain/types@11.1.2': resolution: {integrity: sha512-U+HFai5+zmJCkK86QsaJtoITlboZHBqrVketcO2ROv865xfCMSFpELQoz1GkX5GzME8pTa+3kbKrZHQtI0gdbw==} + '@clack/core@0.3.5': + resolution: {integrity: sha512-5cfhQNH+1VQ2xLQlmzXMqUoiaH0lRBq9/CLW9lTyMbuKLC3+xEK01tHVvyut++mLOn5urSHmkm6I0Lg9MaJSTQ==} + + '@clack/prompts@0.7.0': + resolution: {integrity: sha512-0MhX9/B4iL6Re04jPrttDm+BsP8y6mS7byuv0BvXgdXhbV5PdlsHt55dvNsuBCPZ7xq1oTAOOuotR9NFbQyMSA==} + bundledDependencies: + - is-unicode-supported + '@cspotcode/source-map-support@0.8.1': resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} engines: {node: '>=12'} @@ -9605,6 +9641,9 @@ packages: resolution: {integrity: sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==} engines: {node: '>=18'} + sisteransi@1.0.5: + resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} + slash@3.0.0: resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} engines: {node: '>=8'} @@ -12303,6 +12342,17 @@ snapshots: '@chevrotain/types@11.1.2': {} + '@clack/core@0.3.5': + dependencies: + picocolors: 1.1.1 + sisteransi: 1.0.5 + + '@clack/prompts@0.7.0': + dependencies: + '@clack/core': 0.3.5 + picocolors: 1.1.1 + sisteransi: 1.0.5 + '@cspotcode/source-map-support@0.8.1': dependencies: '@jridgewell/trace-mapping': 0.3.9 @@ -16758,12 +16808,6 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-plugin-es@4.1.0(eslint@10.5.0): - dependencies: - eslint: 10.5.0 - eslint-utils: 2.1.0 - regexpp: 3.2.0 - eslint-plugin-es@4.1.0(eslint@8.57.1): dependencies: eslint: 8.57.1 @@ -16830,8 +16874,8 @@ snapshots: dependencies: builtins: 5.1.0 eslint: 10.5.0 - eslint-plugin-es: 4.1.0(eslint@10.5.0) - eslint-utils: 3.0.0(eslint@10.5.0) + eslint-plugin-es: 4.1.0(eslint@8.57.1) + eslint-utils: 3.0.0(eslint@8.57.1) ignore: 5.3.2 is-core-module: 2.16.2 minimatch: 3.1.5 @@ -16923,11 +16967,6 @@ snapshots: dependencies: eslint-visitor-keys: 1.3.0 - eslint-utils@3.0.0(eslint@10.5.0): - dependencies: - eslint: 10.5.0 - eslint-visitor-keys: 2.1.0 - eslint-utils@3.0.0(eslint@8.57.1): dependencies: eslint: 8.57.1 @@ -21297,6 +21336,8 @@ snapshots: mrmime: 2.0.1 totalist: 3.0.1 + sisteransi@1.0.5: {} + slash@3.0.0: {} smart-buffer@4.2.0: {}