diff --git a/AGENTS.md b/AGENTS.md index 02e73dc0..42f10558 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -36,6 +36,8 @@ The `pnpm test` script intentionally runs `build` first so `tsnapi` snapshots co Ahead-of-time build artifacts that live under `src/` - the shadow-root stylesheets in `packages/hub-ui/src/client/.generated/` and `packages/json-render-ui/src/.generated/` - are **generated, not committed** (`.generated` is gitignored). Each owning package builds its own with `pnpm run build:css`, and three things guarantee the file is on disk before anything imports it: the root `postinstall` runs `turbo run build:css`, the Turbo `typecheck` task depends on both `build:css` tasks, and each package's `build` script chains `build:css` first. A new generated-under-`src` artifact follows the same shape - its own build script, declared `outputs` in `turbo.json`, and a `typecheck` dependency - rather than being checked in, since a minified single-line blob conflicts on every concurrent edit. +**`starter/`** is the top-level, self-contained template for creating a new devframe integration (Vanilla TS, Vite client, playgrounds, tests). It uses real versions in its `package.json` (no catalogs, no `workspace:*`) so it's copy-paste ready for users. Pnpm links its `devframe`/`@devframes/*` dependencies to the local workspace copies during development. When `bumpp -r` bumps the repo versions, `bumpp.config.ts` runs `scripts/sync-starter-version.ts` to update the starter's dependencies to match. + `pnpm knip` finds unused files, dependencies, and exports across every workspace (config in `knip.jsonc`). It runs against source directly - no prior build needed. Most workspaces need no configuration; `knip.jsonc` only carries per-workspace overrides for cases knip's defaults can't infer on their own: a package's non-`index.ts` `exports` subpaths (knip's package.json→`dist`→`src` source mapping needs a workspace `tsconfig.json` `outDir`, which conflicts with this repo's cross-workspace `src/*.ts` imports, so multi-entry packages list their `exports`-mapped entry files explicitly instead - keep that list in sync with each `tsdown.config.ts`), config files knip's plugins don't discover in a nested location (a Next.js app rooted below the workspace root, `storybook-solidjs-vite` not matching the Storybook plugin trigger), and dependencies referenced dynamically outside its static import graph (icon collections consumed by UnoCSS at build time, plugin packages loaded via a runtime `import()` string). Prefer fixing the underlying gap or a scoped `ignoreDependencies`/`entry` override over a blanket `ignore`. ## Conventions diff --git a/bump.config.ts b/bump.config.ts new file mode 100644 index 00000000..3ae364fc --- /dev/null +++ b/bump.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from 'bumpp' +import { syncStarterVersion } from './scripts/sync-starter-version.ts' + +export default defineConfig({ + // `starter/` pins real `devframe`/`@devframes/*` versions (it's a + // copy-paste-ready template, not a workspace member consuming + // `catalog:`/`workspace:*`), so `bumpp -r` can't reach it on its own - + // sync it here, before the version-bump commit is made. + execute: operation => syncStarterVersion(operation.state.newVersion), +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index be89ef3e..07fd2dcd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2411,6 +2411,52 @@ importers: specifier: catalog:testing version: 4.1.10(@types/node@26.2.0)(vite@8.2.1(@types/node@26.2.0)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.47.1)(tsx@4.23.12)(yaml@2.9.0)) + starter: + dependencies: + '@devframes/hub': + specifier: ^0.9.4 + version: 0.9.4(crossws@0.4.10(srvx@0.12.4))(devframe@packages+devframe) + '@devframes/hub-ui': + specifier: ^0.9.4 + version: 0.9.4(@devframes/hub@0.9.4(crossws@0.4.10(srvx@0.12.4))(devframe@packages+devframe))(@devframes/json-render@0.9.4(@devframes/hub@0.9.4(crossws@0.4.10(srvx@0.12.4))(devframe@packages+devframe))(devframe@packages+devframe))(devframe@packages+devframe) + '@devframes/vite': + specifier: ^0.9.4 + version: 0.9.4(@devframes/hub-ui@0.9.4(@devframes/hub@0.9.4(crossws@0.4.10(srvx@0.12.4))(devframe@packages+devframe))(@devframes/json-render@0.9.4(@devframes/hub@0.9.4(crossws@0.4.10(srvx@0.12.4))(devframe@packages+devframe))(devframe@packages+devframe))(devframe@packages+devframe))(@devframes/hub@0.9.4(crossws@0.4.10(srvx@0.12.4))(devframe@packages+devframe))(devframe@packages+devframe)(vite@8.2.1(@types/node@26.2.0)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.47.1)(tsx@4.23.12)(yaml@2.9.0)) + devframe: + specifier: workspace:* + version: link:../packages/devframe + devDependencies: + '@antfu/eslint-config': + specifier: ^9.3.0 + version: 9.3.0(@typescript-eslint/typescript-estree@8.67.0(supports-color@10.2.2)(typescript@6.0.3))(@typescript-eslint/utils@8.67.0(eslint@10.8.1(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3))(@vue/compiler-sfc@3.5.41)(eslint@10.8.1(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3)(vitest@4.1.10(@types/node@26.2.0)(vite@8.2.1(@types/node@26.2.0)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.47.1)(tsx@4.23.12)(yaml@2.9.0))) + '@playwright/test': + specifier: ^1.62.1 + version: 1.62.1 + '@types/node': + specifier: ^26.2.0 + version: 26.2.0 + '@types/ws': + specifier: ^8.18.1 + version: 8.18.1 + bumpp: + specifier: ^12.2.1 + version: 12.2.1 + eslint: + specifier: ^10.8.1 + version: 10.8.1(jiti@2.7.0)(supports-color@10.2.2) + typescript: + specifier: ^6.0.3 + version: 6.0.3 + vite: + specifier: ^8.2.1 + version: 8.2.1(@types/node@26.2.0)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.47.1)(tsx@4.23.12)(yaml@2.9.0) + vitest: + specifier: ^4.1.10 + version: 4.1.10(@types/node@26.2.0)(vite@8.2.1(@types/node@26.2.0)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.47.1)(tsx@4.23.12)(yaml@2.9.0)) + ws: + specifier: ^8.21.3 + version: 8.21.3 + storybook: dependencies: '@antfu/design': @@ -2775,11 +2821,26 @@ packages: '@colordx/core@5.4.3': resolution: {integrity: sha512-kIxYSfA5T8HXjav55UaaH/o/cKivF6jCCGIb8eqtcsfI46wsvlSiT8jMDyrl779qLec3c2c2oHBZo4oAhvbjrQ==} + '@devframes/hub-ui@0.9.4': + resolution: {integrity: sha512-KKg0w+LHJd6IZOiwQd5oX2URT8xBw/zmyWGCwnAfDjagLPhgYhwWTsviRYsFYl+odsm08IdnKT6tACzNqUlt7A==} + peerDependencies: + '@devframes/hub': 0.9.4 + '@devframes/json-render': 0.9.4 + devframe: workspace:* + peerDependenciesMeta: + '@devframes/json-render': + optional: true + '@devframes/hub@0.7.14': resolution: {integrity: sha512-Ym3YHkBnpdwhPw7y4YgURZYntQPShb9raRfo60D1Yk4jb1OlnL2GGHw6pt4iLZqHL4vp1P4T6YpBFPPVx4G38A==} peerDependencies: devframe: workspace:* + '@devframes/hub@0.9.4': + resolution: {integrity: sha512-Yn4V+HavKm/aTdZOPhT+bD7L1cCt/bYKTmXuh2do1ol6YwHaSl6+C1WZDxthl6HWcevpOEwwBwNa9kZAeMFkOQ==} + peerDependencies: + devframe: workspace:* + '@devframes/json-render@0.7.14': resolution: {integrity: sha512-Bo+IiRpEdceQjLr/tcFNXFYlBkSeUv7PWsFc4Fu5PLUyxnnqciLVyFjZz6IlEtKrBIA667L4QbQe5ZExlPhlPA==} peerDependencies: @@ -2789,6 +2850,30 @@ packages: '@devframes/hub': optional: true + '@devframes/json-render@0.9.4': + resolution: {integrity: sha512-G31AY9evtDehW9OYtmRhkzeFasYGV9XNZ1qZYh5GmiPYY88IZrqSBvyz+tYUca/Wx34JgrwL8vmGRycUuT8haQ==} + peerDependencies: + '@devframes/hub': 0.9.4 + devframe: workspace:* + peerDependenciesMeta: + '@devframes/hub': + optional: true + + '@devframes/vite@0.9.4': + resolution: {integrity: sha512-OYMHXBzBKtPc1rdYIPoYSPdFZL+B52YmjPkM/L6qSUI5AfqdtVyO87ydwgTbWkL8Q2HqUXXiSsR8FUfj6iUIDw==} + peerDependencies: + '@devframes/hub': 0.9.4 + '@devframes/hub-ui': 0.9.4 + devframe: workspace:* + vite: ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + '@devframes/hub': + optional: true + '@devframes/hub-ui': + optional: true + vite: + optional: true + '@discoveryjs/discovery@1.0.0-beta.99': resolution: {integrity: sha512-Dy374TnEbEJpgNDogwHHeY9bMFwnNOvs5V8qGZmMyfn0irGsoKldb+wi6d5xY+9DZ9G3D6WJ9M/jKaB7bjvJqg==} engines: {node: '>=8.0.0'} @@ -11413,6 +11498,13 @@ snapshots: '@colordx/core@5.4.3': {} + '@devframes/hub-ui@0.9.4(@devframes/hub@0.9.4(crossws@0.4.10(srvx@0.12.4))(devframe@packages+devframe))(@devframes/json-render@0.9.4(@devframes/hub@0.9.4(crossws@0.4.10(srvx@0.12.4))(devframe@packages+devframe))(devframe@packages+devframe))(devframe@packages+devframe)': + dependencies: + '@devframes/hub': 0.9.4(crossws@0.4.10(srvx@0.12.4))(devframe@packages+devframe) + devframe: link:packages/devframe + optionalDependencies: + '@devframes/json-render': 0.9.4(@devframes/hub@0.9.4(crossws@0.4.10(srvx@0.12.4))(devframe@packages+devframe))(devframe@packages+devframe) + '@devframes/hub@0.7.14(devframe@packages+devframe)': dependencies: birpc: 4.1.0 @@ -11425,6 +11517,20 @@ snapshots: zigpty: 0.2.1 optional: true + '@devframes/hub@0.9.4(crossws@0.4.10(srvx@0.12.4))(devframe@packages+devframe)': + dependencies: + '@standard-schema/spec': 1.1.0 + destr: 2.0.5 + devframe: link:packages/devframe + h3: 2.0.1-rc.26(crossws@0.4.10(srvx@0.12.4)) + pathe: 2.0.3 + perfect-debounce: 2.1.0 + tinyexec: 1.3.0 + ufo: 1.6.4 + zigpty: 0.2.1 + transitivePeerDependencies: + - crossws + '@devframes/json-render@0.7.14(@devframes/hub@0.7.14(devframe@packages+devframe))(devframe@packages+devframe)': dependencies: '@json-render/core': 0.19.0(zod@4.4.3) @@ -11435,6 +11541,24 @@ snapshots: '@devframes/hub': 0.7.14(devframe@packages+devframe) optional: true + '@devframes/json-render@0.9.4(@devframes/hub@0.9.4(crossws@0.4.10(srvx@0.12.4))(devframe@packages+devframe))(devframe@packages+devframe)': + dependencies: + '@json-render/core': 0.19.0(zod@4.4.3) + devframe: link:packages/devframe + zod: 4.4.3 + optionalDependencies: + '@devframes/hub': 0.9.4(crossws@0.4.10(srvx@0.12.4))(devframe@packages+devframe) + optional: true + + '@devframes/vite@0.9.4(@devframes/hub-ui@0.9.4(@devframes/hub@0.9.4(crossws@0.4.10(srvx@0.12.4))(devframe@packages+devframe))(@devframes/json-render@0.9.4(@devframes/hub@0.9.4(crossws@0.4.10(srvx@0.12.4))(devframe@packages+devframe))(devframe@packages+devframe))(devframe@packages+devframe))(@devframes/hub@0.9.4(crossws@0.4.10(srvx@0.12.4))(devframe@packages+devframe))(devframe@packages+devframe)(vite@8.2.1(@types/node@26.2.0)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.47.1)(tsx@4.23.12)(yaml@2.9.0))': + dependencies: + devframe: link:packages/devframe + pathe: 2.0.3 + optionalDependencies: + '@devframes/hub': 0.9.4(crossws@0.4.10(srvx@0.12.4))(devframe@packages+devframe) + '@devframes/hub-ui': 0.9.4(@devframes/hub@0.9.4(crossws@0.4.10(srvx@0.12.4))(devframe@packages+devframe))(@devframes/json-render@0.9.4(@devframes/hub@0.9.4(crossws@0.4.10(srvx@0.12.4))(devframe@packages+devframe))(devframe@packages+devframe))(devframe@packages+devframe) + vite: 8.2.1(@types/node@26.2.0)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.47.1)(tsx@4.23.12)(yaml@2.9.0) + '@discoveryjs/discovery@1.0.0-beta.99': dependencies: '@discoveryjs/json-ext': 0.6.3 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 1c4bc77a..99db0ad7 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -16,6 +16,10 @@ minimumReleaseAgeExclude: - tsdown@0.22.14 - verkit@0.3.0 - structured-clone-es@2.0.1 + - '@devframes/hub-ui@0.9.4' + - '@devframes/hub@0.9.4' + - '@devframes/json-render@0.9.4' + - '@devframes/vite@0.9.4' shamefullyHoist: true shellEmulator: true strictPeerDependencies: false @@ -28,6 +32,7 @@ packages: - plugins/*/assets-pkg - services/* - examples/* + - starter - storybook - docs diff --git a/scripts/sync-starter-version.ts b/scripts/sync-starter-version.ts new file mode 100644 index 00000000..6f2e8976 --- /dev/null +++ b/scripts/sync-starter-version.ts @@ -0,0 +1,36 @@ +import { readFile, writeFile } from 'node:fs/promises' +import path from 'node:path' +import process from 'node:process' +import { fileURLToPath } from 'node:url' + +const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') +const starterPkgPath = path.resolve(rootDir, 'starter/package.json') + +/** + * Rewrites `starter/package.json`'s `devframe`/`@devframes/*` dependency + * ranges to `^` — the starter is a self-contained, copy-paste-ready + * template that pins real versions rather than `catalog:`/`workspace:*`, so + * a repo-wide bump has to touch it explicitly. Called from `bump.config.ts`'s + * `execute` hook so `bumpp -r` keeps it in lockstep automatically. + */ +export async function syncStarterVersion(version: string): Promise { + const raw = await readFile(starterPkgPath, 'utf-8') + const pkg = JSON.parse(raw) + + for (const name of Object.keys(pkg.dependencies ?? {})) { + if (name === 'devframe' || name.startsWith('@devframes/')) + pkg.dependencies[name] = `^${version}` + } + + await writeFile(starterPkgPath, `${JSON.stringify(pkg, null, 2)}\n`) +} + +// Allow standalone invocation: `tsx scripts/sync-starter-version.ts `. +if (import.meta.url === `file://${process.argv[1]}`) { + const version = process.argv[2] + if (!version) { + console.error('Usage: tsx scripts/sync-starter-version.ts ') + process.exit(1) + } + await syncStarterVersion(version) +} diff --git a/scripts/verify-typecheck-coverage.ts b/scripts/verify-typecheck-coverage.ts index 6141adde..6bb03a51 100644 --- a/scripts/verify-typecheck-coverage.ts +++ b/scripts/verify-typecheck-coverage.ts @@ -22,7 +22,7 @@ const rootDir = resolve(dirname(fileURLToPath(import.meta.url)), '..') /** * Workspace globs, mirrored from `pnpm-workspace.yaml`'s `packages:` list. */ -const WORKSPACE_PATTERNS = ['packages/*', 'plugins/*', 'examples/*', 'storybook', 'docs'] +const WORKSPACE_PATTERNS = ['packages/*', 'plugins/*', 'examples/*', 'starter', 'storybook', 'docs'] /** * Packages with a `tsconfig.json` that intentionally don't have a diff --git a/starter/.gitignore b/starter/.gitignore new file mode 100644 index 00000000..8e62df58 --- /dev/null +++ b/starter/.gitignore @@ -0,0 +1,9 @@ +.DS_Store +.turbo +*.log +coverage +dist +node_modules +playwright-report +test-results +temp diff --git a/starter/README.md b/starter/README.md new file mode 100644 index 00000000..8f8e2e40 --- /dev/null +++ b/starter/README.md @@ -0,0 +1,38 @@ +# devframe-starter + +Self-contained starter for a single [devframe](https://github.com/devframes/devframe) integration: a vanilla-TS Vite client, a CLI dev/build/MCP shell, single + hub playgrounds, and unit + e2e tests. Copy this folder out of the monorepo as the seed for a new devframe - it pins real dependency versions (no `catalog:`/`workspace:*`) so it installs standalone. + +## Run + +```sh +pnpm install +pnpm run build # build the vanilla-TS SPA into dist/client +pnpm run dev # CLI dev server - http://localhost:7391/__devframe-starter/ +pnpm run cli:build # static deploy in ./dist/static +pnpm run play:single # Vite playground: SPA at /, RPC bridge at /__devframe-starter/ +pnpm run play:hub # Vite playground: mounted as a dock inside a devframes hub +pnpm run test # unit tests (vitest) +pnpm run test:e2e # e2e tests (playwright, against the single playground) +pnpm run lint +pnpm run typecheck +``` + +`pnpm run dev` and both playgrounds gate by default: opening the printed URL walks you through devframe's interactive OTP handshake (a 6-digit code) before the SPA can call RPC. That's intentional - see the `auth` comments in `src/devframe.ts` and `playground/*/vite.config.ts` before reaching for `auth: false`, which trusts every connection that can reach the port. For a one-off loopback-only session, pass `--no-auth` to the CLI instead (`pnpm run dev -- --no-auth`). + +## File map + +| Path | Purpose | +|------|---------| +| `src/devframe.ts` | The single `DevframeDefinition` every surface below consumes. | +| `src/rpc/` | The one RPC function (`get-state` - a query+snapshot returning runtime info and a directory listing) and its namespace declaration. | +| `src/client/` | The vanilla-TS SPA: `index.html`, `main.ts`, `app.ts`, `styles.css`. | +| `src/shared/base-path.ts` | The devframe's base path, shared between the node-side definition and browser-side client entries. | +| `bin.mjs` | `createCac(devframe).parse()` - exposes `dev`, `build`, `mcp`. | +| `playground/single/` | Vite dev-serves the SPA (with HMR) while `devframeViteBridge` answers RPC/discovery at the devframe's own base - see the comment in its `vite.config.ts` for why the two can't share one base. | +| `playground/hub/` | A minimal `@devframes/hub` host that mounts this devframe as an iframe dock (requires `pnpm run build` first). | +| `test/` | Unit tests - RPC functions over a real WebSocket, no browser. | +| `e2e/` | Playwright tests against the single playground, using the checked-in `e2e/fixtures/` directory as a fixed working directory. | + +## Versioning + +Dependencies here are real semver ranges, not the monorepo's pnpm catalog - so this folder is copy-paste ready outside the workspace. When developed in-repo, pnpm links `devframe`/`@devframes/*` to the local workspace packages automatically. See the root `AGENTS.md`'s `starter/` note for how a repo-wide `bumpp -r` release keeps these versions in sync. diff --git a/starter/bin.mjs b/starter/bin.mjs new file mode 100755 index 00000000..d35b5779 --- /dev/null +++ b/starter/bin.mjs @@ -0,0 +1,14 @@ +#!/usr/bin/env node +import process from 'node:process' +import { createCac } from 'devframe/adapters/cac' +import devframe from './src/devframe.ts' + +async function main() { + const cli = createCac(devframe) + await cli.parse() +} + +main().catch((error) => { + console.error(error) + process.exit(1) +}) diff --git a/starter/e2e/app.test.ts b/starter/e2e/app.test.ts new file mode 100644 index 00000000..02679005 --- /dev/null +++ b/starter/e2e/app.test.ts @@ -0,0 +1,33 @@ +import process from 'node:process' +import { expect, test } from '@playwright/test' + +// Exercises the single playground end to end: the vanilla-TS SPA connects +// over the real WebSocket RPC bridge and renders the `get-state` result for +// the fixed `./fixtures` directory (wired via `playwright.config.ts`'s +// `webServer.env.DEVFRAME_E2E_CWD`). +test('connects and renders the list of items', async ({ page }) => { + await page.goto('/') + + const list = page.locator('ul') + await expect(list).toBeVisible() + + const items = list.locator('li') + await expect(items).toHaveCount(2) + await expect(items.nth(0)).toContainText('dir1') + await expect(items.nth(0)).toContainText('dir') + await expect(items.nth(1)).toContainText('file1.txt') + await expect(items.nth(1)).toContainText('file') + + // The "node" info line renders from the same `get-state` call. + await expect(page.locator('.meta code').first()).toContainText(process.version) +}) + +test('refresh re-fetches the list without a page reload', async ({ page }) => { + await page.goto('/') + + const refreshButton = page.getByRole('button', { name: 'Refresh' }) + await expect(refreshButton).toBeEnabled() + await refreshButton.click() + + await expect(page.locator('ul li')).toHaveCount(2) +}) diff --git a/starter/e2e/fixtures/dir1/.gitkeep b/starter/e2e/fixtures/dir1/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/starter/e2e/fixtures/file1.txt b/starter/e2e/fixtures/file1.txt new file mode 100644 index 00000000..ce013625 --- /dev/null +++ b/starter/e2e/fixtures/file1.txt @@ -0,0 +1 @@ +hello diff --git a/starter/eslint.config.js b/starter/eslint.config.js new file mode 100644 index 00000000..67174045 --- /dev/null +++ b/starter/eslint.config.js @@ -0,0 +1,19 @@ +// @ts-check +import antfu from '@antfu/eslint-config' + +export default antfu( + { + type: 'app', + pnpm: true, + }, + { + // This starter is a self-contained, copy-paste-ready template — it + // intentionally pins real versions instead of pnpm catalog references + // (see the root AGENTS.md's "starter/" note), so the pnpm plugin's own + // catalog-enforcement rule doesn't apply to its `package.json`. + files: ['package.json'], + rules: { + 'pnpm/json-enforce-catalog': 'off', + }, + }, +) diff --git a/starter/package.json b/starter/package.json new file mode 100644 index 00000000..00f20024 --- /dev/null +++ b/starter/package.json @@ -0,0 +1,57 @@ +{ + "name": "devframe-starter", + "type": "module", + "version": "0.9.4", + "private": true, + "packageManager": "pnpm@11.22.0", + "description": "Self-contained starter for a single devframe integration: a vanilla-TS Vite client, single + hub playgrounds, and unit + e2e tests.", + "author": "Anthony Fu ", + "license": "MIT", + "homepage": "https://github.com/devframes/devframe/tree/main/starter", + "repository": { + "directory": "starter", + "type": "git", + "url": "git+https://github.com/devframes/devframe.git" + }, + "bugs": "https://github.com/devframes/devframe/issues", + "keywords": [ + "devframe", + "devtools", + "starter", + "template" + ], + "main": "src/devframe.ts", + "bin": { + "devframe-starter": "./bin.mjs" + }, + "scripts": { + "dev": "node bin.mjs", + "build": "vite build --config src/client/vite.config.ts", + "cli:build": "node bin.mjs build --out-dir dist/static", + "play:single": "vite --config playground/single/vite.config.ts", + "play:hub": "vite --config playground/hub/vite.config.ts", + "lint": "eslint", + "test": "vitest run", + "test:e2e": "playwright test", + "typecheck": "tsc --noEmit", + "release": "bumpp" + }, + "dependencies": { + "@devframes/hub": "^0.9.4", + "@devframes/hub-ui": "^0.9.4", + "@devframes/vite": "^0.9.4", + "devframe": "^0.9.4" + }, + "devDependencies": { + "@antfu/eslint-config": "^9.3.0", + "@playwright/test": "^1.62.1", + "@types/node": "^26.2.0", + "@types/ws": "^8.18.1", + "bumpp": "^12.2.1", + "eslint": "^10.8.1", + "typescript": "^6.0.3", + "vite": "^8.2.1", + "vitest": "^4.1.10", + "ws": "^8.21.3" + } +} diff --git a/starter/playground/hub/index.html b/starter/playground/hub/index.html new file mode 100644 index 00000000..dfeea4db --- /dev/null +++ b/starter/playground/hub/index.html @@ -0,0 +1,16 @@ + + + + + + Devframe Starter (Hub Playground) + + +

Hub Playground

+

This page is the host app. The devtools ride along:

+
    +
  • the floating dock (bottom right) is /__devframes/embedded.js, injected by the hub plugin
  • +
  • the standalone viewer lives at /__devframes/
  • +
+ + diff --git a/starter/playground/hub/vite.config.ts b/starter/playground/hub/vite.config.ts new file mode 100644 index 00000000..890df1d0 --- /dev/null +++ b/starter/playground/hub/vite.config.ts @@ -0,0 +1,47 @@ +import type { HubDevframeEntry } from '@devframes/hub/initiate' +import { fileURLToPath } from 'node:url' +import { createUi } from '@devframes/hub-ui' +import { viteDevframeHub } from '@devframes/vite/hub' +import { defineConfig } from 'vite' +import devframe from '../../src/devframe.ts' + +// The hub playground: a minimal Vite host that mounts this devframe as a dock +// inside the devframe hub, using the default @devframes/hub-ui. +// +// The hub synthesizes an iframe dock that points to the devframe's built +// SPA (at `dist/client`, which must be built first) and mounts it alongside +// other hub tools. + +// Customize the synthesized iframe dock entry: title and icon. +const entry: HubDevframeEntry = { + devframe, + dock: { + title: 'Starter', + icon: 'ph:rocket-launch-duotone', + }, +} + +export default defineConfig({ + // This config's own directory - `index.html` lives here, not at the cwd + // the `play:hub` script runs from (the starter's package root). + root: fileURLToPath(new URL('.', import.meta.url)), + // Dev tooling reached from arbitrary hostnames (LAN IPs, tunnels): accept + // any Host header and fall back to the next free port when busy. + server: { allowedHosts: true, strictPort: false }, + plugins: [ + viteDevframeHub({ + // This playground demonstrates mounting a hub directly in Vite, not + // recommending it over Vite DevTools - silence the one-time notice. + quiet: true, + devframes: [entry], + // Use the default hub-ui, rebrand it to match the starter's accent color. + ui: createUi({ branding: { primaryColor: '#10b981', productName: 'Devframe Starter' } }), + // `auth` is left unset: gated by default (devframe's interactive + // OTP - a 6-digit code printed to the terminal, entered once in the + // reference UI's authorization view). `auth: false` would trust + // every connection that can reach this port instead; only reach for + // it on a tool that's genuinely single-user and loopback-only. See + // docs/guide/security.md. + }), + ], +}) diff --git a/starter/playground/single/index.html b/starter/playground/single/index.html new file mode 100644 index 00000000..4a3f3d22 --- /dev/null +++ b/starter/playground/single/index.html @@ -0,0 +1,12 @@ + + + + + + Devframe Starter (Single Playground) + + +
+ + + diff --git a/starter/playground/single/main.ts b/starter/playground/single/main.ts new file mode 100644 index 00000000..1b414d69 --- /dev/null +++ b/starter/playground/single/main.ts @@ -0,0 +1,21 @@ +import { mount } from '../../src/client/app.ts' +import { BASE_PATH } from '../../src/shared/base-path.ts' +import '../../src/client/styles.css' + +// Mirrors `src/client/main.ts` (the real entry the CLI/build serve), except +// it points `connectDevframe` at the RPC bridge's mount base explicitly: this +// playground serves the SPA from Vite's own root for HMR, while +// `devframeViteBridge` (which claims 100% of requests under its own base - +// it can't share one with Vite's SPA serving) answers at `BASE_PATH`. +const mq = window.matchMedia('(prefers-color-scheme: dark)') +function applyScheme(dark: boolean): void { + document.documentElement.classList.toggle('dark', dark) +} +applyScheme(mq.matches) +mq.addEventListener('change', e => applyScheme(e.matches)) + +const root = document.getElementById('app') +if (!root) + throw new Error('#app mount node missing from index.html') + +void mount(root, { baseURL: BASE_PATH }) diff --git a/starter/playground/single/vite.config.ts b/starter/playground/single/vite.config.ts new file mode 100644 index 00000000..2e62c0e4 --- /dev/null +++ b/starter/playground/single/vite.config.ts @@ -0,0 +1,44 @@ +import process from 'node:process' +import { fileURLToPath } from 'node:url' +import { devframeViteBridge } from '@devframes/vite/single' +import { defineConfig } from 'vite' +import devframe from '../../src/devframe.ts' + +// The single playground: Vite dev-serves the vanilla-TS SPA (this +// directory's own `index.html`/`main.ts`, at the server root, with full +// HMR) while `devframeViteBridge` answers the RPC/WS/discovery routes at +// the devframe's own base path (`/__devframe-starter/`) - a *different* +// prefix than the SPA's, since the bridge's middleware claims every request +// under its base with no passthrough and so can't share one with Vite's own +// SPA serving. `playground/single/main.ts` points `connectDevframe` at that +// base explicitly instead of relying on `document.baseURI`. +export default defineConfig({ + root: fileURLToPath(new URL('.', import.meta.url)), + server: { + // Explicit IPv4: the default `localhost` host can resolve to the IPv6 + // loopback on some systems, which the pinned `port` below doesn't fix + // by itself (e2e tooling dials `127.0.0.1` directly). + host: '127.0.0.1', + // Dev tooling reached from arbitrary hostnames (LAN IPs, tunnels): + // accept any Host header. + allowedHosts: true, + // Pinned (rather than an auto-picked free port) so `playwright.config.ts` + // can point at a known URL. + port: devframe.cli!.port, + strictPort: true, + }, + plugins: [ + devframeViteBridge(devframe, { + base: devframe.basePath, + // Gated by default (devframe's interactive OTP) when unset, same as + // `bin.mjs dev` - see the comment on `cli` in `src/devframe.ts`. + // `DEVFRAME_E2E` is set only by `playwright.config.ts`'s + // `webServer.env`, so the automated e2e suite can drive the bridge + // without solving the OTP prompt; a plain `pnpm run play:single` + // still gates. Don't widen this to a bare `auth: false` - that + // trusts every connection that can reach the port, not just this + // test harness. See docs/guide/security.md. + auth: process.env.DEVFRAME_E2E ? false : undefined, + }), + ], +}) diff --git a/starter/playwright.config.ts b/starter/playwright.config.ts new file mode 100644 index 00000000..11dead74 --- /dev/null +++ b/starter/playwright.config.ts @@ -0,0 +1,46 @@ +import process from 'node:process' +import { fileURLToPath } from 'node:url' +import { defineConfig, devices } from '@playwright/test' +import devframe from './src/devframe.ts' + +// Fixed, checked-in fixture dir (not a per-test tmp dir): the dev server +// this config boots is a separate process started before any test runs, so +// its `DEVFRAME_E2E_CWD` has to be set here - a test's `process.env` can't +// reach back into an already-running child process. +const fixtureCwd = fileURLToPath(new URL('./e2e/fixtures', import.meta.url)) +const port = devframe.cli!.port // matches `playground/single/vite.config.ts`'s pinned port. + +export default defineConfig({ + testDir: './e2e', + testIgnore: ['fixtures/**'], + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + reporter: process.env.CI ? [['github'], ['html']] : 'list', + use: { + baseURL: `http://127.0.0.1:${port}`, + trace: 'on-first-retry', + }, + projects: [ + { name: 'chromium', use: { ...devices['Desktop Chrome'] } }, + ], + webServer: { + command: 'pnpm run play:single', + env: { + DEVFRAME_E2E_CWD: fixtureCwd, + // Tells the bridge to skip the interactive OTP handshake - see the + // `auth` comment in `playground/single/vite.config.ts`. Real usage + // (a human running `pnpm run play:single`) never sets this and stays + // gated. + DEVFRAME_E2E: '1', + }, + // The SPA (this playground's own `index.html`) serves from Vite's root - + // see `playground/single/vite.config.ts` for why it can't share the + // RPC bridge's `/__devframe-starter/` base. + url: `http://127.0.0.1:${port}/`, + timeout: 60_000, + reuseExistingServer: !process.env.CI, + stdout: 'pipe', + stderr: 'pipe', + }, +}) diff --git a/starter/src/client/app.ts b/starter/src/client/app.ts new file mode 100644 index 00000000..a505c41d --- /dev/null +++ b/starter/src/client/app.ts @@ -0,0 +1,108 @@ +import type { DevframeScopedClientContext } from 'devframe/client' +import type { StarterItem, StarterState } from '../rpc/functions/get-state.ts' +import { connectDevframe } from 'devframe/client' + +const NAMESPACE = 'devframe-starter' +type StarterCtx = DevframeScopedClientContext + +function h( + tag: K, + attrs: Partial> = {}, + children: (Node | string)[] = [], +): HTMLElementTagNameMap[K] { + const el = document.createElement(tag) + for (const [k, v] of Object.entries(attrs)) { + if (v != null) + el.setAttribute(k, v) + } + for (const c of children) + el.append(typeof c === 'string' ? document.createTextNode(c) : c) + return el +} + +export interface MountOptions { + /** + * Override where `connectDevframe` looks for `__connection.json`, instead + * of deriving it from `document.baseURI`. The CLI dev server and the + * static build serve this SPA *at* the devframe's own base path, so + * `document.baseURI` already resolves there and this can stay unset; the + * single playground serves it from Vite's own root instead (Vite's SPA + * serving and the RPC bridge can't share one base - see + * `playground/single/vite.config.ts`), so its entry passes this explicitly. + */ + baseURL?: string +} + +/** Boot the SPA into `root`: connect to devframe, then render its state. */ +export async function mount(root: HTMLElement, options: MountOptions = {}): Promise { + root.replaceChildren(h('div', { class: 'connecting' }, ['Connecting to devframe…'])) + + const client = await connectDevframe(options.baseURL ? { baseURL: options.baseURL } : undefined) + const ctx = client.scope(NAMESPACE) as StarterCtx + + const listCard = h('div', { class: 'card' }) + const count = h('span', { class: 'count' }, ['0']) + const nodeCode = h('code', {}, ['…']) + const cwdCode = h('code', {}, ['…']) + const refreshBtn = h('button', { type: 'button' }, ['Refresh']) + + async function refresh(): Promise { + refreshBtn.disabled = true + refreshBtn.textContent = 'Loading…' + try { + // The one RPC round trip this SPA makes. + const state = await ctx.rpc.call('get-state') as StarterState + count.textContent = String(state.items.length) + nodeCode.textContent = state.node + cwdCode.textContent = state.cwd + renderList(listCard, state.items) + } + finally { + refreshBtn.disabled = false + refreshBtn.textContent = 'Refresh' + } + } + refreshBtn.addEventListener('click', () => void refresh()) + + root.replaceChildren( + h('div', { class: 'app' }, [ + h('header', { class: 'nav' }, [ + h('span', { class: 'brand' }, [h('span', { class: 'dot' }), 'Devframe Starter']), + h('span', { class: 'spacer' }), + h('small', { class: 'meta' }, [ + 'node ', + nodeCode, + ' · backend ', + h('code', {}, [ctx.base.connectionMeta.backend]), + ]), + ]), + h('main', {}, [ + h('div', { class: 'row' }, [ + h('h2', {}, ['Items']), + count, + h('span', { class: 'spacer' }), + refreshBtn, + ]), + listCard, + h('p', { class: 'meta' }, ['cwd: ', cwdCode]), + ]), + ]), + ) + + await refresh() +} + +function renderList(card: HTMLElement, items: StarterItem[]): void { + if (items.length === 0) { + card.replaceChildren(h('p', { class: 'empty' }, ['Nothing in the working directory.'])) + return + } + card.replaceChildren( + h('ul', {}, items.map(it => + h('li', {}, [ + it.name, + h('span', { class: 'spacer' }), + h('span', { class: 'kind' }, [it.kind]), + ]))), + ) +} diff --git a/starter/src/client/index.html b/starter/src/client/index.html new file mode 100644 index 00000000..54d49435 --- /dev/null +++ b/starter/src/client/index.html @@ -0,0 +1,13 @@ + + + + + + + Devframe Starter + + +
+ + + diff --git a/starter/src/client/main.ts b/starter/src/client/main.ts new file mode 100644 index 00000000..67156e3e --- /dev/null +++ b/starter/src/client/main.ts @@ -0,0 +1,16 @@ +import { mount } from './app.ts' +import './styles.css' + +// Mirror the OS color scheme onto so the CSS `.dark` variables apply. +const mq = window.matchMedia('(prefers-color-scheme: dark)') +function applyScheme(dark: boolean): void { + document.documentElement.classList.toggle('dark', dark) +} +applyScheme(mq.matches) +mq.addEventListener('change', e => applyScheme(e.matches)) + +const root = document.getElementById('app') +if (!root) + throw new Error('#app mount node missing from index.html') + +void mount(root) diff --git a/starter/src/client/shims.d.ts b/starter/src/client/shims.d.ts new file mode 100644 index 00000000..7c6b5f61 --- /dev/null +++ b/starter/src/client/shims.d.ts @@ -0,0 +1,2 @@ +// Side-effect style CSS import used by the SPA entry. +declare module '*.css' {} diff --git a/starter/src/client/styles.css b/starter/src/client/styles.css new file mode 100644 index 00000000..011f90e6 --- /dev/null +++ b/starter/src/client/styles.css @@ -0,0 +1,168 @@ +:root { + color-scheme: light dark; + --bg: #ffffff; + --fg: #1a1a1a; + --muted: #6b7280; + --border: #e5e7eb; + --accent: #10b981; + --card: #f9fafb; + --hover: #f3f4f6; +} + +html.dark { + --bg: #0b0f13; + --fg: #e5e7eb; + --muted: #9ca3af; + --border: #1f2937; + --accent: #34d399; + --card: #111827; + --hover: #1f2937; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; + background: var(--bg); + color: var(--fg); +} + +.app { + min-height: 100vh; + display: flex; + flex-direction: column; +} + +.nav { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.75rem 1rem; + border-bottom: 1px solid var(--border); +} + +.brand { + display: flex; + align-items: center; + gap: 0.5rem; + font-weight: 600; +} + +.dot { + width: 0.6rem; + height: 0.6rem; + border-radius: 999px; + background: var(--accent); +} + +.spacer { + flex: 1; +} + +.meta { + font-family: ui-monospace, "SF Mono", Menlo, monospace; + font-size: 0.75rem; + color: var(--muted); +} + +.meta code { + color: var(--fg); +} + +main { + flex: 1; + width: 100%; + max-width: 48rem; + margin: 0 auto; + padding: 1rem; + display: flex; + flex-direction: column; + gap: 1rem; +} + +.row { + display: flex; + align-items: center; + gap: 0.5rem; +} + +h2 { + font-size: 1rem; + margin: 0; +} + +.count { + font-family: ui-monospace, monospace; + font-size: 0.75rem; + padding: 0.1rem 0.4rem; + border-radius: 0.35rem; + background: var(--card); + color: var(--muted); +} + +button { + font: inherit; + cursor: pointer; + padding: 0.35rem 0.7rem; + border-radius: 0.4rem; + border: 1px solid var(--border); + background: var(--card); + color: var(--fg); +} + +button:disabled { + opacity: 0.6; + cursor: default; +} + +.card { + border: 1px solid var(--border); + border-radius: 0.5rem; + overflow: hidden; +} + +ul { + list-style: none; + margin: 0; + padding: 0; +} + +li { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.45rem 0.75rem; + border-bottom: 1px solid var(--border); + font-family: ui-monospace, monospace; + font-size: 0.85rem; +} + +li:last-child { + border-bottom: none; +} + +li:hover { + background: var(--hover); +} + +.kind { + font-size: 0.7rem; + color: var(--muted); +} + +.empty { + padding: 2.5rem 0.75rem; + text-align: center; + color: var(--muted); + font-size: 0.85rem; +} + +.connecting { + display: grid; + place-items: center; + min-height: 100vh; + color: var(--muted); +} diff --git a/starter/src/client/vite.config.ts b/starter/src/client/vite.config.ts new file mode 100644 index 00000000..bef65829 --- /dev/null +++ b/starter/src/client/vite.config.ts @@ -0,0 +1,14 @@ +import { fileURLToPath } from 'node:url' +import { defineConfig } from 'vite' + +// Builds the standalone SPA into `dist/client` (the definition's `cli.distDir`). +// `base: './'` keeps every asset URL relative so the same bundle works under +// any mount path - the CLI static build, the single playground, or a hub dock. +export default defineConfig({ + base: './', + root: fileURLToPath(new URL('.', import.meta.url)), + build: { + outDir: fileURLToPath(new URL('../../dist/client', import.meta.url)), + emptyOutDir: true, + }, +}) diff --git a/starter/src/devframe.ts b/starter/src/devframe.ts new file mode 100644 index 00000000..a2085dce --- /dev/null +++ b/starter/src/devframe.ts @@ -0,0 +1,43 @@ +import { fileURLToPath } from 'node:url' +import { defineDevframe } from 'devframe' +import pkg from '../package.json' with { type: 'json' } +import { NAMESPACE, serverFunctions } from './rpc/index.ts' +import { BASE_PATH } from './shared/base-path.ts' + +const distDir = fileURLToPath(new URL('../dist/client', import.meta.url)) + +/** + * The single `DevframeDefinition` every surface consumes: the CLI + * (`bin.mjs`), the single playground, and the hub playground all import + * this one object. + */ +export default defineDevframe({ + id: 'devframe-starter', + name: 'Devframe Starter', + version: pkg.version, + packageName: pkg.name, + importMetaUrl: import.meta.url, + homepage: pkg.homepage, + description: pkg.description, + icon: 'ph:rocket-launch-duotone', + basePath: BASE_PATH, + cli: { + command: 'devframe-starter', + port: 7391, + distDir, + // `auth` is deliberately left unset: gated by default (devframe's + // interactive OTP handshake - a 6-digit code printed to the terminal + // that trusts the browser before it can call any RPC function). + // `auth: false` would trust *any* connection that can reach the port + // instead - see docs/guide/security.md before reaching for it. A + // developer who wants to skip the prompt for a one-off loopback-only + // session can pass `--no-auth` per run (`devframe-starter --no-auth`) + // rather than baking the opt-out into the definition. + }, + setup(ctx) { + // A scoped context auto-namespaces every registered id with `NAMESPACE:`. + const my = ctx.scope(NAMESPACE) + for (const fn of serverFunctions) + my.rpc.register(fn) + }, +}) diff --git a/starter/src/rpc/functions/get-state.ts b/starter/src/rpc/functions/get-state.ts new file mode 100644 index 00000000..bfdf3856 --- /dev/null +++ b/starter/src/rpc/functions/get-state.ts @@ -0,0 +1,38 @@ +import { readdir } from 'node:fs/promises' +import process from 'node:process' +import { defineRpcFunction } from 'devframe' + +export interface StarterItem { + name: string + kind: 'dir' | 'file' +} + +export interface StarterState { + cwd: string + node: string + items: StarterItem[] +} + +/** + * A `query` RPC with `snapshot: true`: live over WebSocket in dev, and its + * dump is baked into a static build so the SPA keeps working with no server. + * The one round trip the client makes - runtime info plus the top-level + * entries of the working directory. + */ +export const getState = defineRpcFunction({ + name: 'get-state', + type: 'query', + jsonSerializable: true, + snapshot: true, + setup: ctx => ({ + handler: async (): Promise => { + const cwd = process.env.DEVFRAME_E2E_CWD || ctx.cwd + const entries = await readdir(cwd, { withFileTypes: true }) + const items = entries + .filter(e => !e.name.startsWith('.')) + .map(e => ({ name: e.name, kind: e.isDirectory() ? 'dir' as const : 'file' as const })) + .sort((a, b) => a.name.localeCompare(b.name)) + return { cwd, node: process.version, items } + }, + }), +}) diff --git a/starter/src/rpc/index.ts b/starter/src/rpc/index.ts new file mode 100644 index 00000000..e82d21ce --- /dev/null +++ b/starter/src/rpc/index.ts @@ -0,0 +1,14 @@ +import type { RpcDefinitionsToFunctionsWithNamespace } from 'devframe/rpc' +import { getState } from './functions/get-state.ts' + +export const NAMESPACE = 'devframe-starter' + +export const serverFunctions = [getState] as const + +declare module 'devframe' { + // Functions are defined with bare names and registered through a scoped + // context, so the registry key is namespaced to match the runtime id + // (`devframe-starter:get-state`). + interface DevframeRpcServerFunctions + extends RpcDefinitionsToFunctionsWithNamespace {} +} diff --git a/starter/src/shared/base-path.ts b/starter/src/shared/base-path.ts new file mode 100644 index 00000000..90045cd5 --- /dev/null +++ b/starter/src/shared/base-path.ts @@ -0,0 +1,6 @@ +// Shared between the node-side devframe definition and browser-side client +// entries - a plain string constant with no node/browser-specific imports, +// so it's safe to bundle into either. +// +// Colon-free so it stays a valid `/` segment when mounted in a hub. +export const BASE_PATH = '/__devframe-starter/' diff --git a/starter/test/get-state.test.ts b/starter/test/get-state.test.ts new file mode 100644 index 00000000..d18f0e0e --- /dev/null +++ b/starter/test/get-state.test.ts @@ -0,0 +1,73 @@ +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' +import process from 'node:process' +import { initDevframe } from 'devframe/initiate' +import { createRpcClient } from 'devframe/rpc/client' +import { createWsRpcChannel } from 'devframe/rpc/transports/ws-client' +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' +import { WebSocket } from 'ws' +import devframe from '../src/devframe.ts' + +// The public `initDevframe` handler used by every hosted adapter (see +// `@devframes/vite/single`) - a side-car WS server on a free port, no SPA +// (`distDir: false`), so this test exercises `get-state` over the real wire +// without booting the CLI dev server or building the client first. +vi.stubGlobal('WebSocket', WebSocket) + +describe('get-state', () => { + let tmpDir: string + let instance: ReturnType + let rpc: ReturnType> + + beforeAll(async () => { + tmpDir = await mkdtemp(path.join(os.tmpdir(), 'devframe-starter-test-')) + await writeFile(path.join(tmpDir, 'file1.txt'), '') + await writeFile(path.join(tmpDir, 'file2.txt'), '') + await mkdir(path.join(tmpDir, 'dir1')) + // `get-state` falls back to this env var over `ctx.cwd` so tests + // control the working directory without touching the real `process.cwd()`. + process.env.DEVFRAME_E2E_CWD = tmpDir + + instance = initDevframe(devframe, { + base: devframe.basePath!, + distDir: false, + // Explicit IPv4: `host: 'localhost'` (the default) can resolve to the + // IPv6 loopback on some systems, while the client below dials `127.0.0.1` + // directly - an address-family mismatch that reads as a silent hang. + host: '127.0.0.1', + ws: { sidecar: true }, + // Fine here (unlike the "real" surfaces in `src/devframe.ts` and + // `playground/`): this instance is a private, ephemeral test fixture + // - bound to loopback, torn down in `afterAll`, and never reachable + // by anything but this test process. + auth: false, + }) + await instance.ready + + // A side-car binds its WS endpoint at `/` (default `__ws`), so + // `connectionMeta().websocket` is `{ port, path }` here - not a bare port. + const wsMeta = instance.connectionMeta().websocket as { port: number, path: string } + const channel = createWsRpcChannel({ url: `ws://127.0.0.1:${wsMeta.port}/${wsMeta.path}` }) + rpc = createRpcClient({}, { channel }) + }) + + afterAll(async () => { + await instance.close() + await rm(tmpDir, { recursive: true, force: true }) + delete process.env.DEVFRAME_E2E_CWD + }) + + it('returns runtime info and the working directory listing, sorted with dotfiles excluded', async () => { + const state = await rpc.$call('devframe-starter:get-state') + expect(state).toEqual({ + node: process.version, + cwd: tmpDir, + items: [ + { name: 'dir1', kind: 'dir' }, + { name: 'file1.txt', kind: 'file' }, + { name: 'file2.txt', kind: 'file' }, + ], + }) + }) +}) diff --git a/starter/tsconfig.json b/starter/tsconfig.json new file mode 100644 index 00000000..f82c3375 --- /dev/null +++ b/starter/tsconfig.json @@ -0,0 +1,27 @@ +{ + "compilerOptions": { + "target": "ESNext", + "jsx": "preserve", + "lib": ["ESNext", "DOM", "DOM.Iterable"], + "module": "ESNext", + "moduleResolution": "Bundler", + "resolveJsonModule": true, + "types": ["node", "vite/client"], + "allowImportingTsExtensions": true, + "strict": true, + "strictNullChecks": true, + "noEmit": true, + "esModuleInterop": true, + "verbatimModuleSyntax": true, + "skipLibCheck": true + }, + "include": [ + "src", + "playground", + "test", + "e2e", + "scripts", + "bin.mjs", + "*.config.ts" + ] +} diff --git a/starter/vitest.config.ts b/starter/vitest.config.ts new file mode 100644 index 00000000..2051bd24 --- /dev/null +++ b/starter/vitest.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + environment: 'node', + include: ['test/**/*.test.ts'], + // Don't run e2e tests here - they have their own config and script. + exclude: ['e2e/**'], + }, +}) diff --git a/turbo.json b/turbo.json index 39859e00..3b09ceef 100644 --- a/turbo.json +++ b/turbo.json @@ -147,6 +147,11 @@ "dependsOn": ["devframe#build"], "outputs": ["dist/**"] }, + "devframe-starter#build": { + "outputLogs": "new-only", + "dependsOn": ["devframe#build"], + "outputs": ["dist/**"] + }, "streaming-chat-example#build": { "outputLogs": "new-only", "dependsOn": ["devframe#build"], @@ -173,6 +178,12 @@ "env": ["DEVFRAME_E2E_CWD"], "outputs": ["dist/static/**"] }, + "devframe-starter#cli:build": { + "outputLogs": "new-only", + "dependsOn": ["devframe-starter#build"], + "env": ["DEVFRAME_E2E_CWD"], + "outputs": ["dist/static/**"] + }, "streaming-chat-example#cli:build": { "outputLogs": "new-only", "dependsOn": ["streaming-chat-example#build"], diff --git a/vitest.config.ts b/vitest.config.ts index 05861af3..4faf5a7b 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -35,6 +35,7 @@ export default defineConfig({ 'examples/hub-next', 'packages/next', 'packages/vite', + 'starter', { test: { name: 'tests',