From ded50cf658595d23dfc02afbb7b9d40e5d878eee Mon Sep 17 00:00:00 2001 From: Vivek Date: Sun, 12 Jul 2026 10:56:02 +0530 Subject: [PATCH 01/39] feat: add a json() column helper to the scaffold db seam Persisting a structured value (a board, a tag list, a settings blob) is one of the first things a real app needs, but the generated db/columns.server.ts stopped at scalar helpers, so a first feature had to reach for text({ mode: 'json' }) from outside Drizzle knowledge. Add a generic json() helper to both dialect seams (SQLite text({ mode: 'json' }), Postgres jsonb) and demonstrate it once on the example schema, so the one schema still compiles on both dialects. Surfaced by dogfooding a first feature build (#931). --- agent-docs/orm.md | 8 +++++- packages/cli/lib/create.js | 13 +++++++-- test/scaffolds/scaffold-integration.test.js | 29 +++++++++++++++++++++ 3 files changed, 47 insertions(+), 3 deletions(-) diff --git a/agent-docs/orm.md b/agent-docs/orm.md index 862c9cf50..2b66f41d4 100644 --- a/agent-docs/orm.md +++ b/agent-docs/orm.md @@ -159,7 +159,7 @@ return { success: true, data: { ...formatPost(row), authorName: me.name } }; ## Column helpers live in `db/columns.server.ts` (the one dialect seam) The schema is written against helpers (`table`, `pk`, `uuidPk`, `uuid`, `bool`, -`timestamp`, `createdAt`, `updatedAt`, `index`) rather than raw drizzle +`json`, `timestamp`, `createdAt`, `updatedAt`, `index`) rather than raw drizzle builders, so the same `db/schema.server.ts` runs on SQLite and Postgres. Only `db/columns.server.ts` differs per dialect. Two of those helpers exist because rc.3 has not yet exposed a stable no-arg overload. @@ -172,6 +172,12 @@ rc.3 has not yet exposed a stable no-arg overload. argument the runtime auto-fills. The helper synthesizes drizzle-kit's own collision-free name, so schema authors call `index(t.createdAt)` with no name. Replace it with a bare `index()` once 1.0 stable ships the no-arg overload. +- **`json()`** persists a structured value (an array or object) as JSON, + typed by `T` so reads and writes are narrowed instead of `unknown`. It is + `text({ mode: 'json' }).$type()` on SQLite and `jsonb().$type()` on + Postgres, so `board: json()` or `settings: json<{ theme?: string }>()` + works unchanged on both dialects. Reach for it whenever a column holds + structured data rather than a scalar (a board, a tag list, a settings blob). When you add a table, use the helpers (`pk()` for an integer autoincrement id, `uuidPk()` for an app-generated uuid string id, `createdAt()` for a diff --git a/packages/cli/lib/create.js b/packages/cli/lib/create.js index 5bfe88b80..74a164374 100644 --- a/packages/cli/lib/create.js +++ b/packages/cli/lib/create.js @@ -675,6 +675,9 @@ export const table = sqliteTableCreator((name) => name, 'snake_case'); export const pk = () => integer().primaryKey({ autoIncrement: true }); export const uuidPk = () => text().primaryKey().$defaultFn(() => crypto.randomUUID()); export const uuid = () => text(); +// Structured value (array / object) stored as JSON. Type it with json() so +// the column is narrowed on read/write instead of \`unknown\`. +export const json = () => text({ mode: 'json' }).$type(); export const bool = () => integer({ mode: 'boolean' }); export const timestamp = () => integer({ mode: 'timestamp_ms' }); export const createdAt = () => timestamp().notNull().defaultNow(); @@ -686,7 +689,7 @@ export const index = (...cols: SQLiteColumn[]) => _index(getTableName((cols[0] as unknown as { table: Table }).table) + '_' + cols.map((c) => c.name).join('_') + '_idx').on(...(cols as [SQLiteColumn, ...SQLiteColumn[]])); `; - const columnsPg = `import { pgTableCreator, serial, uuid as pgUuid, integer, text, real, boolean, timestamp as pgTimestamp, index as _index } from 'drizzle-orm/pg-core'; + const columnsPg = `import { pgTableCreator, serial, uuid as pgUuid, integer, text, real, boolean, jsonb, timestamp as pgTimestamp, index as _index } from 'drizzle-orm/pg-core'; import type { PgColumn } from 'drizzle-orm/pg-core'; import { getTableName, type Table } from 'drizzle-orm'; @@ -697,6 +700,9 @@ export const table = pgTableCreator((name) => name, 'snake_case'); export const pk = () => serial().primaryKey(); export const uuidPk = () => pgUuid().primaryKey().defaultRandom(); export const uuid = () => pgUuid(); +// Structured value (array / object) stored as JSON. Type it with json() so +// the column is narrowed on read/write instead of \`unknown\`. +export const json = () => jsonb().$type(); export const bool = () => boolean(); export const timestamp = () => pgTimestamp({ withTimezone: true }); export const createdAt = () => timestamp().notNull().defaultNow(); @@ -710,13 +716,16 @@ export const index = (...cols: PgColumn[]) => // Example schema (dialect-agnostic). Replace the User model with your own. await writeFile(join(appDir, 'db', 'schema.server.ts'), `import { defineRelations } from 'drizzle-orm'; -import { table, pk, ${isFullStack ? 'uuidPk, ' : ''}text, ${isFullStack ? 'bool, ' : ''}createdAt } from './columns.server.ts'; +import { table, pk, ${isFullStack ? 'uuidPk, ' : ''}text, ${isFullStack ? 'bool, ' : ''}json, createdAt } from './columns.server.ts'; // Example model. Feel free to delete or extend. export const users = table('users', { id: pk(), email: text().notNull().unique(), name: text(), + // JSON column: a structured value persisted as JSON, typed via json(). + // Same helper works on SQLite and Postgres. Delete if you do not need it. + settings: json<{ theme?: string }>(), createdAt: createdAt(), }); ${isFullStack ? ` diff --git a/test/scaffolds/scaffold-integration.test.js b/test/scaffolds/scaffold-integration.test.js index 21ab57f34..0ba2ab4d2 100644 --- a/test/scaffolds/scaffold-integration.test.js +++ b/test/scaffolds/scaffold-integration.test.js @@ -136,6 +136,15 @@ test('scaffoldApp full-stack: writes the canonical full-stack app layout', async assert.ok(existsSync(join(appDir, 'db', 'columns.server.ts')), 'db/columns.server.ts written'); assert.ok(existsSync(join(appDir, 'db', 'connection.server.ts')), 'db/connection.server.ts written'); assert.ok(existsSync(join(appDir, 'drizzle.config.ts')), 'drizzle.config.ts written'); + // A JSON column helper ships so persisting structured data (a board, a tag + // list, a settings blob) needs no outside Drizzle knowledge. SQLite path + // uses text({ mode: 'json' }). The example schema demonstrates it once. + const colsSrc = readFileSync(join(appDir, 'db', 'columns.server.ts'), 'utf8'); + assert.match(colsSrc, /export const json = \(\) => text\(\{ mode: 'json' \}\)\.\$type\(\)/, + 'sqlite columns.server.ts exports a generic json() helper'); + const schemaSrc = readFileSync(join(appDir, 'db', 'schema.server.ts'), 'utf8'); + assert.match(schemaSrc, /\bjson\b/, 'schema imports json'); + assert.match(schemaSrc, /json<\{[^}]*\}>\(\)/, 'schema demonstrates json() on a column (counterfactual: fails if the demo column is dropped)'); assert.ok(!existsSync(join(appDir, 'prisma')), 'no prisma/ dir (counterfactual: fails if db files not written)'); // The INITIAL migration is authored by `webjs create` only AFTER a successful // install (drizzle-kit is needed). This test scaffolds with install:false, so @@ -619,3 +628,23 @@ test('scaffoldApp: template placeholder substitution in copied files', async () await rm(cwd, { recursive: true, force: true }); } }); + +test('scaffoldApp --db postgres: json() helper maps to jsonb, one schema both dialects', async () => { + const cwd = await tempCwd(); + const restore = muteConsole(); + try { + await scaffoldApp('my-pg', cwd, { template: 'full-stack', db: 'postgres' }); + const appDir = join(cwd, 'my-pg'); + const cols = readFileSync(join(appDir, 'db', 'columns.server.ts'), 'utf8'); + // The Postgres seam uses jsonb; the schema (shared across dialects) is + // unchanged, so the same json() call compiles on both. + assert.match(cols, /jsonb/, 'postgres columns.server.ts imports jsonb'); + assert.match(cols, /export const json = \(\) => jsonb\(\)\.\$type\(\)/, + 'postgres json() maps to jsonb().$type() (counterfactual: fails if the helper is sqlite-only)'); + const schema = readFileSync(join(appDir, 'db', 'schema.server.ts'), 'utf8'); + assert.match(schema, /json<\{[^}]*\}>\(\)/, 'the one shared schema demonstrates json() on postgres too'); + } finally { + restore(); + await rm(cwd, { recursive: true, force: true }); + } +}); From c8b2b7a0389b2bd69a0f9351d3257f47fa3f594d Mon Sep 17 00:00:00 2001 From: Vivek Date: Sun, 12 Jul 2026 10:58:40 +0530 Subject: [PATCH 02/39] fix: guide past the db generate TTY dead-end on a table rename webjs db generate off a non-interactive stdin dead-ends when a scaffold table is renamed or swapped: drizzle-kit asks whether the new table is a rename of the old one and, with no TTY to answer, fails with "Interactive prompts require a TTY". Surface the escape hatch (run in a real terminal, or delete the initial migration when the dev DB has no data) instead of leaving the raw drizzle-kit error as the last word. The interactive and successful paths print nothing extra. Surfaced by dogfooding a first feature build (#931). --- packages/cli/bin/webjs.js | 11 ++++++++- packages/cli/lib/db-hints.js | 25 +++++++++++++++++++++ packages/cli/test/db-hints/db-hints.test.js | 19 ++++++++++++++++ 3 files changed, 54 insertions(+), 1 deletion(-) create mode 100644 packages/cli/lib/db-hints.js create mode 100644 packages/cli/test/db-hints/db-hints.test.js diff --git a/packages/cli/bin/webjs.js b/packages/cli/bin/webjs.js index dce29c138..018875c70 100755 --- a/packages/cli/bin/webjs.js +++ b/packages/cli/bin/webjs.js @@ -3,6 +3,7 @@ import { resolve, join, dirname } from 'node:path'; import { spawn } from 'node:child_process'; import { fileURLToPath } from 'node:url'; import { resolveBin } from '../lib/resolve-bin.js'; +import { dbGenerateTtyHint } from '../lib/db-hints.js'; import { checkNodeInline, nodeInlineMessage } from '../lib/node-preflight.js'; import { loadAppEnv, resolvePort } from '../lib/port.js'; import { planDevSupervisor } from '../lib/dev-supervisor.js'; @@ -241,7 +242,15 @@ async function main() { process.exit(1); } const child = spawn(process.execPath, [dkPath, ...kitArgs, ...args], { stdio: 'inherit', cwd: process.cwd() }); - child.on('exit', (code) => process.exit(code ?? 0)); + child.on('exit', (code) => { + // Surface the escape hatch when `generate` dead-ends on a rename prompt + // with no TTY to answer it, instead of leaving the raw drizzle-kit + // "Interactive prompts require a TTY" as the last word. Interactive and + // successful runs print nothing extra. + const hint = dbGenerateTtyHint(sub, code, process.stdin.isTTY); + if (hint) console.error(hint); + process.exit(code ?? 0); + }); break; } case 'ui': { diff --git a/packages/cli/lib/db-hints.js b/packages/cli/lib/db-hints.js new file mode 100644 index 000000000..9e4d803b6 --- /dev/null +++ b/packages/cli/lib/db-hints.js @@ -0,0 +1,25 @@ +// Actionable hints for `webjs db` failure modes that drizzle-kit surfaces +// opaquely. Kept as pure functions so the dispatch path in bin/webjs.js stays a +// thin spawn and the messages are unit-testable without a child process. + +/** + * `webjs db generate` off a non-interactive stdin dead-ends: when a table is + * renamed or swapped, drizzle-kit asks "is a rename of ?" + * and, with no TTY to answer, exits with "Interactive prompts require a TTY". + * Returns the escape-hatch hint for exactly that case, else null (so the + * interactive path and every other subcommand print nothing extra). + * + * @param {string} sub the `webjs db` subcommand (generate|migrate|push|studio) + * @param {number|null|undefined} code drizzle-kit's exit code + * @param {boolean|undefined} isTTY process.stdin.isTTY + * @returns {string|null} + */ +export function dbGenerateTtyHint(sub, code, isTTY) { + if (sub !== 'generate' || !code || isTTY) return null; + return ( + '\nwebjs db generate needs an interactive terminal to resolve a table rename.\n' + + 'Run it in a real terminal to answer the prompt, or, if the dev database has\n' + + 'no data yet, delete the db/migrations/ folder and re-run to author a\n' + + 'clean create-table migration.' + ); +} diff --git a/packages/cli/test/db-hints/db-hints.test.js b/packages/cli/test/db-hints/db-hints.test.js new file mode 100644 index 000000000..5acfd0d77 --- /dev/null +++ b/packages/cli/test/db-hints/db-hints.test.js @@ -0,0 +1,19 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { dbGenerateTtyHint } from '../../lib/db-hints.js'; + +test('dbGenerateTtyHint: surfaces the escape hatch when generate dead-ends off a non-TTY', () => { + const hint = dbGenerateTtyHint('generate', 1, undefined); + assert.ok(hint, 'a hint is returned'); + assert.match(hint, /interactive terminal/, 'names the interactive-terminal fix'); + assert.match(hint, /db\/migrations/, 'names the reset-initial-migration escape'); +}); + +test('dbGenerateTtyHint: stays silent on the paths that do NOT dead-end', () => { + // Counterfactual: each of these must return null, or the hint would fire on a + // healthy run and mislead. + assert.equal(dbGenerateTtyHint('generate', 0, undefined), null, 'success prints nothing'); + assert.equal(dbGenerateTtyHint('generate', 1, true), null, 'an interactive TTY answers the prompt itself'); + assert.equal(dbGenerateTtyHint('migrate', 1, undefined), null, 'only generate has the rename prompt'); + assert.equal(dbGenerateTtyHint('push', 1, undefined), null, 'push is not affected'); +}); From 83677772719eb9f4e759e1077837afb1d3ae343e Mon Sep 17 00:00:00 2001 From: Vivek Date: Sun, 12 Jul 2026 11:03:19 +0530 Subject: [PATCH 03/39] feat: cut the scaffold placeholder-teardown burden A fresh scaffold trips no-scaffold-placeholder on every unadapted demo file at once, so building one feature meant wading through ~32 identical check failures and hand-editing each file to clear the gate. Two eases, neither weakening the rule (a shipped marker still fails): - `webjs check` now collapses the placeholder sentinel into ONE grouped summary listing the files, so real feature violations are not drowned. The returned violations and --json are unchanged (one entry per file). - `webjs check --clear-placeholders` strips every marker comment line in one command (keeping the demo code), turning the sanctioned keep-and-remove path from 30 edits into one. Surfaced by dogfooding a first feature build (#931). --- AGENTS.md | 2 +- packages/cli/bin/webjs.js | 35 +++++++++++- packages/cli/lib/clear-placeholders.js | 57 +++++++++++++++++++ packages/cli/templates/AGENTS.md | 7 ++- packages/cli/templates/CONVENTIONS.md | 4 +- .../clear-placeholders.test.js | 50 ++++++++++++++++ 6 files changed, 150 insertions(+), 5 deletions(-) create mode 100644 packages/cli/lib/clear-placeholders.js create mode 100644 packages/cli/test/clear-placeholders/clear-placeholders.test.js diff --git a/AGENTS.md b/AGENTS.md index ff6afca89..7d9f88ba9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -458,7 +458,7 @@ Rules: **always scaffold via `webjs create`** (never hand-roll). **Default to a webjs dev [--port N] [--no-hot] # dev server with live reload (node --watch on Node, bun --hot on Bun). --no-hot runs in-process. Runs webjs.dev.before + webjs.dev.parallel (#550) webjs start [--port N] # prod server; source IS the runtime, plain HTTP/1.1 (reverse-proxy for TLS + HTTP/2). Runs webjs.start.before first (#550) webjs test [--server] [--browser] [--watch] -webjs check [--rules] [--json] # correctness validator (report-only, no autofix); --json for an agent loop +webjs check [--rules] [--json] [--clear-placeholders] # correctness validator (report-only, no autofix); --json for an agent loop; --clear-placeholders strips scaffold markers in one shot webjs mcp # read-only MCP: routes, actions (RPC hashes), components, check webjs doctor # project-health checklist (incl. a page/layout elision advisory, #646); non-zero exit on a hard fail webjs types # generate .webjs/routes.d.ts (typed Route union + per-route params, #258) diff --git a/packages/cli/bin/webjs.js b/packages/cli/bin/webjs.js index 018875c70..0a7adfe51 100755 --- a/packages/cli/bin/webjs.js +++ b/packages/cli/bin/webjs.js @@ -46,7 +46,7 @@ const USAGE = `webjs commands: (--no-hot: run in-process, no hot-reload supervisor) webjs start [--port 8080] Start production server (serves source directly, no build step) webjs test [--server|--browser] Run server + browser tests - webjs check [--json] Run correctness checks on the app (--json emits structured violations) + webjs check [--json] [--clear-placeholders] Run correctness checks (--json emits structured violations; --clear-placeholders strips scaffold markers) webjs mcp Start the read-only MCP server (routes / actions / components / check) webjs doctor Verify project health (Node, tsconfig, env, vendor pins, importmap coherence, @webjsdev versions, git hook, page/layout elision) webjs types Generate .webjs/routes.d.ts (typed Route union + per-route params) @@ -381,6 +381,23 @@ async function main() { case 'check': { const { checkConventions, RULES } = await import('@webjsdev/server/check'); + // --clear-placeholders: acknowledge the whole scaffold gallery in one + // command (strip the marker comment lines, keep the demo code), instead of + // one hand-edit per file. The gate then reflects only real violations. + if (rest.includes('--clear-placeholders')) { + const { clearPlaceholders } = await import('../lib/clear-placeholders.js'); + const report = clearPlaceholders(process.cwd()); + const total = report.reduce((n, r) => n + r.removed, 0); + if (report.length === 0) { + console.log('webjs check: no scaffold-placeholder markers found (nothing to clear).'); + } else { + console.log(`webjs check: cleared ${total} scaffold-placeholder marker(s) across ${report.length} file(s):`); + for (const r of report) console.log(` ${r.file}`); + console.log('\nThe demo code is kept. Delete any gallery route/module you do not want, then re-run `webjs check`.'); + } + break; + } + if (rest.includes('--rules')) { console.log('webjs check, correctness rules:'); console.log(' Every rule catches code that is wrong to ship: a crash, a'); @@ -415,12 +432,28 @@ async function main() { console.log('webjs check: all checks pass ✓'); } else { console.log(`webjs check: ${violations.length} violation(s) found\n`); + // A fresh scaffold trips no-scaffold-placeholder on every unadapted demo + // file at once. Printing an identical block per file drowns the real + // feature violations, so collapse the sentinel to ONE grouped summary + // (with a one-command clear) and print every other rule per-violation. + // The returned violations and --json are unchanged; only this human + // printout groups, so agents/tools still see one entry per file. + const PLACEHOLDER = 'no-scaffold-placeholder'; + const placeholders = violations.filter((v) => v.rule === PLACEHOLDER); for (const v of violations) { + if (v.rule === PLACEHOLDER) continue; console.log(` ✗ [${v.rule}] ${v.file}`); console.log(` ${v.message}`); if (v.fix) console.log(` Fix: ${v.fix}`); console.log(); } + if (placeholders.length > 0) { + console.log(` ✗ [${PLACEHOLDER}] ${placeholders.length} file(s) still carry scaffold example content:`); + for (const v of placeholders) console.log(` ${v.file}`); + console.log(' Fix: adapt or delete each file, then remove its marker comment line.'); + console.log(' Keeping the gallery? Run `webjs check --clear-placeholders` to clear all markers at once.'); + console.log(); + } process.exit(1); } break; diff --git a/packages/cli/lib/clear-placeholders.js b/packages/cli/lib/clear-placeholders.js new file mode 100644 index 000000000..2e778472c --- /dev/null +++ b/packages/cli/lib/clear-placeholders.js @@ -0,0 +1,57 @@ +// `webjs check --clear-placeholders`: acknowledge the scaffold gallery in one +// command instead of hand-editing every demo file. A fresh scaffold trips +// `no-scaffold-placeholder` on every unadapted file at once, and the rule's +// sanctioned "deliberately keep it, then delete the marker line" path otherwise +// means one manual edit per file. This strips the marker comment lines so the +// gate goes green while the demo CODE is kept verbatim (it does NOT prune the +// gallery; deleting a demo you do not want stays a deliberate `rm`). +import { readFileSync, writeFileSync, readdirSync, statSync } from 'node:fs'; +import { join } from 'node:path'; + +// Assembled so THIS source does not itself carry the contiguous literal (the +// check scans raw source, and this file ships in the published CLI). +export const MARKER = 'webjs-scaffold-' + 'placeholder'; + +const SKIP_DIRS = new Set(['node_modules', '.git', '.webjs', 'graphify-out', 'dist']); + +/** + * Pure: drop every line carrying the marker token. The token only ever appears + * inside dedicated one-line `//` marker comments (the check depends on that), so + * removing a token-carrying line can never delete real code. + * @param {string} content + * @param {string} [marker] + * @returns {{ content: string, removed: number }} + */ +export function stripPlaceholderMarkers(content, marker = MARKER) { + const lines = content.split('\n'); + const kept = lines.filter((line) => !line.includes(marker)); + return { content: kept.join('\n'), removed: lines.length - kept.length }; +} + +/** + * Walk an app root, strip marker lines in place, and return a per-file report of + * how many marker lines were removed. Only `.ts`/`.js`/`.mts`/`.mjs` files that + * actually carry the marker are rewritten. + * @param {string} root + * @param {{ marker?: string, write?: (path: string, content: string) => void }} [opts] + * @returns {Array<{ file: string, removed: number }>} + */ +export function clearPlaceholders(root, opts = {}) { + const marker = opts.marker ?? MARKER; + const write = opts.write ?? ((p, c) => writeFileSync(p, c)); + const report = []; + (function walk(dir) { + for (const name of readdirSync(dir)) { + if (SKIP_DIRS.has(name)) continue; + const full = join(dir, name); + if (statSync(full).isDirectory()) { walk(full); continue; } + if (!/\.m?[jt]s$/.test(name)) continue; + const src = readFileSync(full, 'utf8'); + if (!src.includes(marker)) continue; + const { content, removed } = stripPlaceholderMarkers(src, marker); + write(full, content); + report.push({ file: full, removed }); + } + })(root); + return report; +} diff --git a/packages/cli/templates/AGENTS.md b/packages/cli/templates/AGENTS.md index ac5070cdb..a48a8e1bc 100644 --- a/packages/cli/templates/AGENTS.md +++ b/packages/cli/templates/AGENTS.md @@ -40,8 +40,11 @@ the example `app/page.ts` and `app/layout.ts` carry a `webjs-scaffold-placeholder` marker comment, and `webjs check` fails while any marker remains, so this freshly scaffolded app fails the check until you replace the example content (or deliberately keep it) and -delete the marker line. The delivered app must contain only what the -user asked for, never leftover scaffold code. +delete the marker line. To keep the gallery and clear every marker at +once, run `webjs check --clear-placeholders` (it strips the marker lines +and keeps the demo code), then delete any demo you do not want. The +delivered app must contain only what the user asked for, never leftover +scaffold code. **Non-negotiables for every webjs app:** diff --git a/packages/cli/templates/CONVENTIONS.md b/packages/cli/templates/CONVENTIONS.md index 3001b5c19..bafba83d9 100644 --- a/packages/cli/templates/CONVENTIONS.md +++ b/packages/cli/templates/CONVENTIONS.md @@ -406,7 +406,9 @@ freshly scaffolded app fails `webjs check` until you address each placeholder. The marker is acknowledge-and-remove: replace the example content, or deliberately keep it, and in either case delete the marker line. So the delivered app contains only what the user asked for, never -leftover scaffold code. +leftover scaffold code. To keep the gallery and clear every marker in one +step (instead of one edit per file), run `webjs check --clear-placeholders`, +then delete whichever demo routes/modules you do not want. The scaffold exists so the agent doesn't reinvent the directory layout, the Drizzle wiring, the test runner config, or the convention files. It diff --git a/packages/cli/test/clear-placeholders/clear-placeholders.test.js b/packages/cli/test/clear-placeholders/clear-placeholders.test.js new file mode 100644 index 000000000..a7afbb218 --- /dev/null +++ b/packages/cli/test/clear-placeholders/clear-placeholders.test.js @@ -0,0 +1,50 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { writeFileSync, readFileSync, mkdirSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { stripPlaceholderMarkers, clearPlaceholders, MARKER } from '../../lib/clear-placeholders.js'; + +test('stripPlaceholderMarkers: removes only the marker comment lines, keeps code verbatim', () => { + const src = [ + `// ${MARKER}. Keep and adapt it, or prune it, then delete this marker line.`, + "import { html } from '@webjsdev/core';", + 'export default function Page() { return html`

Hi

`; }', + ].join('\n'); + const { content, removed } = stripPlaceholderMarkers(src); + assert.equal(removed, 1, 'one marker line removed'); + assert.doesNotMatch(content, new RegExp(MARKER), 'marker token is gone'); + assert.match(content, /import \{ html \}/, 'code is preserved'); + assert.match(content, /return html/, 'code is preserved'); +}); + +test('stripPlaceholderMarkers: a file with no marker is untouched (counterfactual)', () => { + const src = "export const x = 1;\nexport const y = 2;\n"; + const { content, removed } = stripPlaceholderMarkers(src); + assert.equal(removed, 0, 'nothing removed'); + assert.equal(content, src, 'content byte-identical'); +}); + +test('clearPlaceholders: walks the app, strips markers, reports per-file, skips node_modules', async () => { + const dir = await mkdtemp(join(tmpdir(), 'webjs-clear-')); + try { + mkdirSync(join(dir, 'app', 'features', 'x'), { recursive: true }); + mkdirSync(join(dir, 'node_modules', 'pkg'), { recursive: true }); + writeFileSync(join(dir, 'app', 'page.ts'), `// ${MARKER}. demo\nexport default () => 'home';\n`); + writeFileSync(join(dir, 'app', 'features', 'x', 'page.ts'), `// ${MARKER}. demo\nexport default () => 'x';\n`); + writeFileSync(join(dir, 'app', 'clean.ts'), `export const ok = true;\n`); + // A marker inside node_modules must NOT be touched (dependency code). + writeFileSync(join(dir, 'node_modules', 'pkg', 'index.js'), `// ${MARKER}\n`); + + const report = clearPlaceholders(dir); + + assert.equal(report.length, 2, 'exactly the two marked app files were rewritten'); + assert.doesNotMatch(readFileSync(join(dir, 'app', 'page.ts'), 'utf8'), new RegExp(MARKER)); + assert.doesNotMatch(readFileSync(join(dir, 'app', 'features', 'x', 'page.ts'), 'utf8'), new RegExp(MARKER)); + assert.match(readFileSync(join(dir, 'node_modules', 'pkg', 'index.js'), 'utf8'), new RegExp(MARKER), + 'node_modules is skipped (counterfactual: fails if the walker descends into deps)'); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); From 9eefd65cd62138f7915ef57cbec6692a00a713c3 Mon Sep 17 00:00:00 2001 From: Vivek Date: Sun, 12 Jul 2026 11:11:38 +0530 Subject: [PATCH 04/39] fix: clear whole multi-line markers and stop misdiagnosing db generate failures Two self-review findings: - stripPlaceholderMarkers removed only the token LINE, but the layout "Built with" footer marker is a multi-line HTML comment and several metadata markers wrap across // lines. That orphaned raw text and a dangling --> into every page's layout. Extend the strip to the whole marker comment (HTML block or contiguous // run), verified by a balanced-comment count on a generated layout. - dbGenerateTtyHint fired on ANY non-TTY generate failure while asserting it was a rename prompt (drizzle-kit's TTY message goes to inherited stderr, which we never capture, so the two are indistinguishable). Reword to conditional guidance that defers other failures to the real error above. --- packages/cli/lib/clear-placeholders.js | 38 ++++++++++++++++--- packages/cli/lib/db-hints.js | 18 +++++---- .../clear-placeholders.test.js | 33 ++++++++++++++++ packages/cli/test/db-hints/db-hints.test.js | 4 +- 4 files changed, 80 insertions(+), 13 deletions(-) diff --git a/packages/cli/lib/clear-placeholders.js b/packages/cli/lib/clear-placeholders.js index 2e778472c..0d6503e04 100644 --- a/packages/cli/lib/clear-placeholders.js +++ b/packages/cli/lib/clear-placeholders.js @@ -15,17 +15,45 @@ export const MARKER = 'webjs-scaffold-' + 'placeholder'; const SKIP_DIRS = new Set(['node_modules', '.git', '.webjs', 'graphify-out', 'dist']); /** - * Pure: drop every line carrying the marker token. The token only ever appears - * inside dedicated one-line `//` marker comments (the check depends on that), so - * removing a token-carrying line can never delete real code. + * Pure: drop the whole marker COMMENT that carries the token, not just the token + * line. The token never shares a line with code, but the marker comment is not + * always one line: the layout footer marker is a multi-line `` HTML + * comment, and several metadata markers wrap across consecutive `//` lines. + * Removing only the token line would orphan the rest (raw text and a dangling + * `-->` inside a template, or leftover `//` prose), so extend to the full + * comment. Both marker styles start on the token line, so extend downward. * @param {string} content * @param {string} [marker] * @returns {{ content: string, removed: number }} */ export function stripPlaceholderMarkers(content, marker = MARKER) { const lines = content.split('\n'); - const kept = lines.filter((line) => !line.includes(marker)); - return { content: kept.join('\n'), removed: lines.length - kept.length }; + const isLineComment = (line) => /^\s*\/\//.test(line); + const kept = []; + let removed = 0; + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + if (!line.includes(marker)) { kept.push(line); continue; } + if (line.includes('')) { + // Multi-line HTML comment (the "Built with" footer): drop through the + // closing `-->` so no orphaned text or dangling terminator survives. + let j = i; + while (j < lines.length && !lines[j].includes('-->')) j++; + removed += j - i + 1; + i = j; + } else if (isLineComment(line)) { + // A `//` marker comment, possibly wrapped across consecutive `//` lines. + // Drop the token line plus its contiguous `//` continuation. + let j = i + 1; + while (j < lines.length && isLineComment(lines[j])) j++; + removed += j - i; + i = j - 1; + } else { + // A self-closed `` on one line, or any other single-line form. + removed += 1; + } + } + return { content: kept.join('\n'), removed }; } /** diff --git a/packages/cli/lib/db-hints.js b/packages/cli/lib/db-hints.js index 9e4d803b6..17d135abe 100644 --- a/packages/cli/lib/db-hints.js +++ b/packages/cli/lib/db-hints.js @@ -3,11 +3,15 @@ // thin spawn and the messages are unit-testable without a child process. /** - * `webjs db generate` off a non-interactive stdin dead-ends: when a table is - * renamed or swapped, drizzle-kit asks "is a rename of ?" + * `webjs db generate` off a non-interactive stdin dead-ends when a table is + * renamed or swapped: drizzle-kit asks "is a rename of ?" * and, with no TTY to answer, exits with "Interactive prompts require a TTY". - * Returns the escape-hatch hint for exactly that case, else null (so the - * interactive path and every other subcommand print nothing extra). + * That message goes to the inherited stderr (this process never captures it), so + * we cannot tell the rename-prompt failure apart from any other generate error. + * The hint therefore reads as CONDITIONAL guidance appended after the real error + * for a non-TTY `generate` failure, and defers to the error shown above for + * anything else. Returns null on success, an interactive run, or another + * subcommand, so those print nothing extra. * * @param {string} sub the `webjs db` subcommand (generate|migrate|push|studio) * @param {number|null|undefined} code drizzle-kit's exit code @@ -17,9 +21,9 @@ export function dbGenerateTtyHint(sub, code, isTTY) { if (sub !== 'generate' || !code || isTTY) return null; return ( - '\nwebjs db generate needs an interactive terminal to resolve a table rename.\n' + - 'Run it in a real terminal to answer the prompt, or, if the dev database has\n' + + '\nIf `webjs db generate` stopped at a rename prompt, it needs an interactive\n' + + 'terminal to answer it: run it in a real terminal, or, if the dev database has\n' + 'no data yet, delete the db/migrations/ folder and re-run to author a\n' + - 'clean create-table migration.' + 'clean create-table migration. Any other failure is described in the error above.' ); } diff --git a/packages/cli/test/clear-placeholders/clear-placeholders.test.js b/packages/cli/test/clear-placeholders/clear-placeholders.test.js index a7afbb218..69f5f6071 100644 --- a/packages/cli/test/clear-placeholders/clear-placeholders.test.js +++ b/packages/cli/test/clear-placeholders/clear-placeholders.test.js @@ -19,6 +19,39 @@ test('stripPlaceholderMarkers: removes only the marker comment lines, keeps code assert.match(content, /return html/, 'code is preserved'); }); +test('stripPlaceholderMarkers: removes a WHOLE multi-line HTML comment marker, no orphaned text', () => { + // The layout footer marker is a 4-line block. Removing only the + // token line would orphan lines 2-4 and a dangling --> as raw text inside the + // html`` template. Regression guard for that corruption. + const src = [ + ' ', + ` ', + '
real footer
', + ].join('\n'); + const { content, removed } = stripPlaceholderMarkers(src); + assert.equal(removed, 3, 'the whole 3-line comment block is removed'); + assert.doesNotMatch(content, new RegExp(MARKER), 'token gone'); + assert.doesNotMatch(content, /branding, not your app/, 'no orphaned prose survives'); + assert.doesNotMatch(content, /-->/, 'no dangling comment terminator'); + assert.match(content, /<\/main>/, 'surrounding markup kept'); + assert.match(content, /