Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -731,6 +731,7 @@ webjs start [--port N] # prod server. No build st
webjs test [--server] [--browser] [--watch] # unit + browser tests
webjs check [--fix] # convention validator
webjs types # generate .webjs/routes.d.ts (typed Route union + per-route params; #258). Opt-in; webjs dev emits it automatically
webjs typecheck [tsc args...] # type-check the app with the project's own tsc --noEmit (non-zero on errors; needs typescript installed)
webjs create <name> [--template api|saas] # scaffold a new app
webjs db <prisma-subcommand> [...] # passthrough to prisma
webjs ui init # @webjsdev/ui CLI
Expand Down
1 change: 1 addition & 0 deletions packages/cli/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ README.md npm-facing package readme.
| `webjs test [--server\|--browser]` | `node --test` for server tests, `wtr` for browser tests |
| `webjs check [--rules\|--fix]` | `checkConventions()` from `@webjsdev/server/check` |
| `webjs types` | `generateRouteTypes()` from `@webjsdev/server`, writes `.webjs/routes.d.ts` (typed `Route` union + per-route params, #258). Also auto-emitted at `webjs dev` startup |
| `webjs typecheck [tsc args]` | Resolves the project's own `typescript/bin/tsc` (via `createRequire` from the app cwd) and spawns it with `--noEmit`, passing extra args through. Exits non-zero on a type error (a CI gate). A clear message + non-zero exit when typescript is not installed (#265). The framework runs the standard compiler, it does not embed one |
| `webjs create <name> [--template …]` | `scaffoldApp()` from `lib/create.js` |
| `webjs db <generate\|migrate\|studio>` | Passthrough to `prisma` |
| `webjs ui <init\|add\|list\|view\|diff\|info>` | Proxies to `@webjsdev/ui` (see "UI subcommand" below) |
Expand Down
27 changes: 27 additions & 0 deletions packages/cli/bin/webjs.js
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ const USAGE = `webjs commands:
webjs test [--server|--browser] Run server + browser tests
webjs check Run correctness checks on the app
webjs types Generate .webjs/routes.d.ts (typed Route union + per-route params)
webjs typecheck [tsc args...] Type-check the app with the project's tsc --noEmit (non-zero on errors)
webjs create <name> [--template full-stack|api|saas] [--no-install] Scaffold a new webjs app
(only 3 templates exist. default: full-stack with Prisma+SQLite)
Auto-runs the detected package manager's install in the new dir
Expand Down Expand Up @@ -299,6 +300,32 @@ async function main() {
);
break;
}
case 'typecheck': {
// Type-check the app with the project's OWN tsc (it reads the app's
// tsconfig: strict + noEmit + erasableSyntaxOnly). The framework runs the
// standard compiler, it does not embed one. Extra args after `typecheck`
// pass through (e.g. `webjs typecheck --watch`). Exits non-zero on a type
// error, so it works as a CI gate and the scaffolded `typecheck` script.
const cwd = process.cwd();
const { createRequire } = await import('node:module');
let tscPath;
try {
const req = createRequire(join(cwd, 'package.json'));
tscPath = req.resolve('typescript/bin/tsc');
} catch {
console.error(
'webjs typecheck: TypeScript is not installed in this project.\n' +
'Install it with `npm install -D typescript`, then re-run `webjs typecheck`.',
);
process.exit(1);
}
const child = spawn(process.execPath, [tscPath, '--noEmit', ...rest], {
stdio: 'inherit',
cwd,
});
child.on('exit', (code) => process.exit(code ?? 1));
break;
}
case 'create': {
const name = rest[0];
if (!name || name.startsWith('-')) {
Expand Down
5 changes: 5 additions & 0 deletions packages/cli/lib/create.js
Original file line number Diff line number Diff line change
Expand Up @@ -286,6 +286,7 @@ export async function scaffoldApp(name, cwd, opts = {}) {
'test:server': 'webjs test --server',
'test:browser': 'webjs test --browser',
check: 'webjs check',
typecheck: 'webjs typecheck',
'db:migrate': 'prisma migrate dev',
'db:generate': 'prisma generate',
'db:studio': 'prisma studio',
Expand All @@ -298,6 +299,10 @@ export async function scaffoldApp(name, cwd, opts = {}) {
},
devDependencies: {
prisma: '^6.0.0',
// The TypeScript compiler, for `npm run typecheck` (webjs typecheck runs
// tsc --noEmit). Not needed at runtime (Node strips types in place), only
// to type-check the app.
typescript: '^5.6.0',
'@web/test-runner': '^0.20.0',
'@web/test-runner-playwright': '^0.11.0',
'playwright': '^1.59.0',
Expand Down
6 changes: 6 additions & 0 deletions packages/cli/templates/CONVENTIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,12 @@ even if the user doesn't explicitly ask.**
4. **Convention check.** Run `webjs check` after changes and fix
any violations before reporting the task as done.

5. **Type check.** Run `npm run typecheck` (which runs `webjs typecheck`,
a `tsc --noEmit` over the app) and fix any type errors. `webjs check` is
correctness-only and does NOT type-check, so this is the separate
is-my-TypeScript-valid gate. It exits non-zero on a type error, so add it
to CI once the app type-checks cleanly.

### Definition of done (MUST be addressed BEFORE opening the PR)

This is the per-PR contract. Before running `gh pr create`, walk through
Expand Down
72 changes: 72 additions & 0 deletions test/cli/typecheck.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
/**
* Tests for `webjs typecheck` (#265): a thin wrapper that runs the project's
* own `tsc --noEmit`, exits non-zero on type errors, and degrades gracefully
* with a clear message when TypeScript is not installed.
*
* The success / type-error fixtures live UNDER the repo so node resolves the
* repo's `typescript` (the CLI resolves tsc relative to the app cwd). The
* graceful-degradation fixture lives in the OS tmpdir, OUTSIDE the repo's
* node_modules tree, so `typescript` is genuinely unresolvable.
*/
import { test, after } from 'node:test';
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import { mkdtempSync, writeFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { resolve, dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';

const __dirname = dirname(fileURLToPath(import.meta.url));
const REPO = resolve(__dirname, '..', '..');
const CLI = resolve(REPO, 'packages', 'cli', 'bin', 'webjs.js');

const TSCONFIG = JSON.stringify({
compilerOptions: {
noEmit: true,
strict: true,
erasableSyntaxOnly: true,
module: 'nodenext',
moduleResolution: 'nodenext',
skipLibCheck: true,
},
include: ['*.ts'],
});

const cleanup = [];
after(() => { for (const d of cleanup) rmSync(d, { recursive: true, force: true }); });

/** Make a fixture app dir with a tsconfig and one source file. */
function makeFixture(baseDir, source) {
const dir = mkdtempSync(join(baseDir, 'tc-'));
cleanup.push(dir);
writeFileSync(join(dir, 'package.json'), '{}');
writeFileSync(join(dir, 'tsconfig.json'), TSCONFIG);
writeFileSync(join(dir, 'app.ts'), source);
return dir;
}

function typecheck(cwd) {
return spawnSync(process.execPath, [CLI, 'typecheck'], { cwd, encoding: 'utf8' });
}

test('webjs typecheck exits 0 on a clean TypeScript app', () => {
const dir = makeFixture(REPO, 'export const n: number = 42;\n');
const r = typecheck(dir);
assert.equal(r.status, 0, `expected exit 0, got ${r.status}\n${r.stdout}\n${r.stderr}`);
});

test('webjs typecheck exits non-zero and reports the error on a type error', () => {
const dir = makeFixture(REPO, 'const n: number = "not a number";\n');
const r = typecheck(dir);
assert.notEqual(r.status, 0, 'a type error must produce a non-zero exit');
assert.match(r.stdout + r.stderr, /TS2322|not assignable/, 'reports the tsc error');
});

test('webjs typecheck degrades gracefully when TypeScript is not installed', () => {
// OS tmpdir is outside the repo node_modules tree, so typescript is unresolvable.
const dir = makeFixture(tmpdir(), 'export const n = 1;\n');
const r = typecheck(dir);
assert.notEqual(r.status, 0, 'exits non-zero when typescript is missing');
assert.match(r.stderr, /TypeScript is not installed/, 'prints a clear message');
assert.match(r.stderr, /npm install -D typescript/, 'tells the user how to fix it');
});