@webjsdev/cli 0.8.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +71 -0
- package/bin/webjs.js +279 -0
- package/lib/create.js +898 -0
- package/lib/saas-template.js +397 -0
- package/package.json +39 -0
- package/templates/.claude/hooks/block-prose-punctuation.sh +236 -0
- package/templates/.claude/hooks/guard-branch-context.sh +39 -0
- package/templates/.claude/hooks/nudge-uncommitted.sh +46 -0
- package/templates/.claude/settings.json +35 -0
- package/templates/.claude.json +9 -0
- package/templates/.cursor/hooks/nudge-uncommitted.sh +38 -0
- package/templates/.cursor/hooks.json +8 -0
- package/templates/.cursorrules +99 -0
- package/templates/.editorconfig +18 -0
- package/templates/.env.example +27 -0
- package/templates/.gemini/hooks/nudge-uncommitted.sh +42 -0
- package/templates/.gemini/settings.json +15 -0
- package/templates/.github/copilot-instructions.md +85 -0
- package/templates/.github/pull_request_template.md +14 -0
- package/templates/.hooks/pre-commit +48 -0
- package/templates/.opencode/plugins/nudge-uncommitted.ts +62 -0
- package/templates/.windsurfrules +91 -0
- package/templates/AGENTS.md +816 -0
- package/templates/CLAUDE.md +2 -0
- package/templates/CONVENTIONS.md +901 -0
- package/templates/lib/utils/ui.ts +83 -0
- package/templates/public/tailwind-browser.js +947 -0
- package/templates/test/hello/browser/hello.test.js +40 -0
- package/templates/test/hello/e2e/hello.test.ts +87 -0
- package/templates/test/hello/hello.test.ts +24 -0
- package/templates/web-test-runner.config.js +33 -0
|
@@ -0,0 +1,397 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SaaS template files for `webjs create --template saas`.
|
|
3
|
+
* Extracted to avoid nested template literal escaping issues.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { mkdir, writeFile, readFile } from 'node:fs/promises';
|
|
7
|
+
import { existsSync } from 'node:fs';
|
|
8
|
+
import { join, resolve, dirname } from 'node:path';
|
|
9
|
+
import { fileURLToPath } from 'node:url';
|
|
10
|
+
|
|
11
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
12
|
+
const UI_REGISTRY_ROOT = resolve(
|
|
13
|
+
__dirname, '..', '..', 'ui', 'packages', 'registry',
|
|
14
|
+
);
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Read a registry component and rewrite its `'../lib/utils.ts'` import for
|
|
18
|
+
* the scaffolded app's `components/ui/<name>.ts` layout (two-up to lib/).
|
|
19
|
+
* Mirrors the helper in `create.js`, kept private here to avoid coupling.
|
|
20
|
+
*/
|
|
21
|
+
async function readUiComponent(name) {
|
|
22
|
+
const src = join(UI_REGISTRY_ROOT, 'components', `${name}.ts`);
|
|
23
|
+
if (!existsSync(src)) return null;
|
|
24
|
+
const raw = await readFile(src, 'utf8');
|
|
25
|
+
return raw
|
|
26
|
+
.replaceAll("'../lib/utils.ts'", "'../../lib/utils.ts'")
|
|
27
|
+
.replaceAll('"../lib/utils.ts"', '"../../lib/utils.ts"');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Copy named registry components into `<appDir>/components/ui/`. */
|
|
31
|
+
async function copyUiComponents(appDir, names) {
|
|
32
|
+
const uiDir = join(appDir, 'components', 'ui');
|
|
33
|
+
await mkdir(uiDir, { recursive: true });
|
|
34
|
+
for (const n of names) {
|
|
35
|
+
const content = await readUiComponent(n);
|
|
36
|
+
if (content == null) continue;
|
|
37
|
+
await writeFile(join(uiDir, `${n}.ts`), content);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* @param {string} appDir
|
|
43
|
+
*/
|
|
44
|
+
export async function writeSaasFiles(appDir) {
|
|
45
|
+
// SaaS pages use auth forms, so copy the extra ui-* components on top of
|
|
46
|
+
// the standard set the full-stack scaffold already wrote. Pre-importing
|
|
47
|
+
// them in login/signup/dashboard pages below means the dev server will
|
|
48
|
+
// SSR these elements with full styling on first paint.
|
|
49
|
+
// `form` and `field` are deferred to v2 (see packages/ui/AGENTS.md) -
|
|
50
|
+
// the saas auth pages use raw <form> + label/input class helpers instead.
|
|
51
|
+
await copyUiComponents(appDir, ['dialog', 'switch', 'checkbox']);
|
|
52
|
+
|
|
53
|
+
// lib/prisma.server.ts
|
|
54
|
+
await mkdir(join(appDir, 'lib'), { recursive: true });
|
|
55
|
+
await writeFile(join(appDir, 'lib', 'prisma.server.ts'), [
|
|
56
|
+
"import { PrismaClient } from '@prisma/client';",
|
|
57
|
+
"",
|
|
58
|
+
"const globalForPrisma = globalThis as unknown as { prisma: PrismaClient };",
|
|
59
|
+
"export const prisma = globalForPrisma.prisma || new PrismaClient();",
|
|
60
|
+
"if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma;",
|
|
61
|
+
"",
|
|
62
|
+
].join('\n'));
|
|
63
|
+
|
|
64
|
+
// lib/password.server.ts
|
|
65
|
+
await writeFile(join(appDir, 'lib', 'password.server.ts'), [
|
|
66
|
+
"import { scrypt, randomBytes, timingSafeEqual } from 'node:crypto';",
|
|
67
|
+
"import { promisify } from 'node:util';",
|
|
68
|
+
"",
|
|
69
|
+
"const scryptAsync = promisify(scrypt);",
|
|
70
|
+
"",
|
|
71
|
+
"export async function hash(password: string): Promise<string> {",
|
|
72
|
+
" const salt = randomBytes(16).toString('hex');",
|
|
73
|
+
" const buf = (await scryptAsync(password, salt, 64)) as Buffer;",
|
|
74
|
+
" return salt + ':' + buf.toString('hex');",
|
|
75
|
+
"}",
|
|
76
|
+
"",
|
|
77
|
+
"export async function compare(password: string, stored: string): Promise<boolean> {",
|
|
78
|
+
" const [salt, key] = stored.split(':');",
|
|
79
|
+
" const buf = (await scryptAsync(password, salt, 64)) as Buffer;",
|
|
80
|
+
" return timingSafeEqual(buf, Buffer.from(key, 'hex'));",
|
|
81
|
+
"}",
|
|
82
|
+
"",
|
|
83
|
+
].join('\n'));
|
|
84
|
+
|
|
85
|
+
// lib/auth.server.ts
|
|
86
|
+
await writeFile(join(appDir, 'lib', 'auth.server.ts'), [
|
|
87
|
+
"import { createAuth, Credentials } from '@webjsdev/server';",
|
|
88
|
+
"import { prisma } from './prisma.server.ts';",
|
|
89
|
+
"import { compare } from './password.server.ts';",
|
|
90
|
+
"",
|
|
91
|
+
"export const { auth, signIn, signOut, handlers } = createAuth({",
|
|
92
|
+
" providers: [",
|
|
93
|
+
" Credentials({",
|
|
94
|
+
" async authorize(credentials: { email: string; password: string }) {",
|
|
95
|
+
" const user = await prisma.user.findUnique({ where: { email: credentials.email } });",
|
|
96
|
+
" if (!user || !await compare(credentials.password, user.passwordHash)) return null;",
|
|
97
|
+
" return { id: String(user.id), name: user.name, email: user.email };",
|
|
98
|
+
" },",
|
|
99
|
+
" }),",
|
|
100
|
+
" ],",
|
|
101
|
+
" secret: process.env.AUTH_SECRET,",
|
|
102
|
+
"});",
|
|
103
|
+
"",
|
|
104
|
+
].join('\n'));
|
|
105
|
+
|
|
106
|
+
// prisma/schema.prisma
|
|
107
|
+
await mkdir(join(appDir, 'prisma'), { recursive: true });
|
|
108
|
+
await writeFile(join(appDir, 'prisma', 'schema.prisma'), [
|
|
109
|
+
'datasource db {',
|
|
110
|
+
' provider = "sqlite"',
|
|
111
|
+
' url = env("DATABASE_URL")',
|
|
112
|
+
'}',
|
|
113
|
+
'',
|
|
114
|
+
'generator client {',
|
|
115
|
+
' provider = "prisma-client-js"',
|
|
116
|
+
'}',
|
|
117
|
+
'',
|
|
118
|
+
'model User {',
|
|
119
|
+
' id Int @id @default(autoincrement())',
|
|
120
|
+
' email String @unique',
|
|
121
|
+
' name String?',
|
|
122
|
+
' passwordHash String',
|
|
123
|
+
' createdAt DateTime @default(now())',
|
|
124
|
+
'}',
|
|
125
|
+
'',
|
|
126
|
+
].join('\n'));
|
|
127
|
+
|
|
128
|
+
// modules/auth/actions/signup.server.ts
|
|
129
|
+
await mkdir(join(appDir, 'modules', 'auth', 'actions'), { recursive: true });
|
|
130
|
+
await mkdir(join(appDir, 'modules', 'auth', 'queries'), { recursive: true });
|
|
131
|
+
|
|
132
|
+
await writeFile(join(appDir, 'modules', 'auth', 'actions', 'signup.server.ts'), [
|
|
133
|
+
"'use server';",
|
|
134
|
+
"",
|
|
135
|
+
"import { prisma } from '../../../lib/prisma.server.ts';",
|
|
136
|
+
"import { hash } from '../../../lib/password.server.ts';",
|
|
137
|
+
"",
|
|
138
|
+
"export async function signup(input: { name: string; email: string; password: string }) {",
|
|
139
|
+
" const exists = await prisma.user.findUnique({ where: { email: input.email } });",
|
|
140
|
+
" if (exists) return { success: false as const, error: 'Email already registered', status: 409 };",
|
|
141
|
+
" const user = await prisma.user.create({",
|
|
142
|
+
" data: { name: input.name, email: input.email, passwordHash: await hash(input.password) },",
|
|
143
|
+
" });",
|
|
144
|
+
" return { success: true as const, data: { id: user.id, name: user.name, email: user.email } };",
|
|
145
|
+
"}",
|
|
146
|
+
"",
|
|
147
|
+
].join('\n'));
|
|
148
|
+
|
|
149
|
+
// modules/auth/queries/current-user.server.ts
|
|
150
|
+
await writeFile(join(appDir, 'modules', 'auth', 'queries', 'current-user.server.ts'), [
|
|
151
|
+
"'use server';",
|
|
152
|
+
"",
|
|
153
|
+
"import { auth } from '../../../lib/auth.server.ts';",
|
|
154
|
+
"",
|
|
155
|
+
"export async function currentUser() {",
|
|
156
|
+
" const session = await auth();",
|
|
157
|
+
" return session?.user ?? null;",
|
|
158
|
+
"}",
|
|
159
|
+
"",
|
|
160
|
+
].join('\n'));
|
|
161
|
+
|
|
162
|
+
// modules/auth/types.ts
|
|
163
|
+
await writeFile(join(appDir, 'modules', 'auth', 'types.ts'), [
|
|
164
|
+
"export interface User {",
|
|
165
|
+
" id: number;",
|
|
166
|
+
" name: string | null;",
|
|
167
|
+
" email: string;",
|
|
168
|
+
"}",
|
|
169
|
+
"",
|
|
170
|
+
"export type ActionResult<T> =",
|
|
171
|
+
" | { success: true; data: T }",
|
|
172
|
+
" | { success: false; error: string; status: number };",
|
|
173
|
+
"",
|
|
174
|
+
].join('\n'));
|
|
175
|
+
|
|
176
|
+
// test/unit/auth.test.ts: minimal stub so the scaffold passes
|
|
177
|
+
// `webjs check` (tests-exist) and `webjs test` runs cleanly out of the
|
|
178
|
+
// box. The signup/current-user functions import from lib/prisma.server.ts
|
|
179
|
+
// and lib/auth.server.ts, both of which need `prisma generate` to have run before
|
|
180
|
+
// they can be imported, so we deliberately test only the runtime-
|
|
181
|
+
// dependency-free types.ts here. Replace with real tests once Prisma
|
|
182
|
+
// is set up (run `npm install && npx prisma migrate dev --name init`).
|
|
183
|
+
await writeFile(join(appDir, 'test', 'unit', 'auth.test.ts'), [
|
|
184
|
+
"import { test } from 'node:test';",
|
|
185
|
+
"import assert from 'node:assert/strict';",
|
|
186
|
+
"",
|
|
187
|
+
"import type { User, ActionResult } from '../../modules/auth/types.ts';",
|
|
188
|
+
"",
|
|
189
|
+
"test('User shape: id is numeric, email is required', () => {",
|
|
190
|
+
" const u: User = { id: 1, name: 'Test', email: 'test@example.com' };",
|
|
191
|
+
" assert.equal(typeof u.id, 'number');",
|
|
192
|
+
" assert.equal(typeof u.email, 'string');",
|
|
193
|
+
"});",
|
|
194
|
+
"",
|
|
195
|
+
"test('ActionResult: success envelope carries data', () => {",
|
|
196
|
+
" const r: ActionResult<User> = {",
|
|
197
|
+
" success: true,",
|
|
198
|
+
" data: { id: 1, name: 'Test', email: 'test@example.com' },",
|
|
199
|
+
" };",
|
|
200
|
+
" assert.equal(r.success, true);",
|
|
201
|
+
" if (r.success) assert.equal(r.data.email, 'test@example.com');",
|
|
202
|
+
"});",
|
|
203
|
+
"",
|
|
204
|
+
"test('ActionResult: failure envelope carries error + status', () => {",
|
|
205
|
+
" const r: ActionResult<User> = {",
|
|
206
|
+
" success: false,",
|
|
207
|
+
" error: 'Email already registered',",
|
|
208
|
+
" status: 409,",
|
|
209
|
+
" };",
|
|
210
|
+
" assert.equal(r.success, false);",
|
|
211
|
+
" if (!r.success) {",
|
|
212
|
+
" assert.equal(r.status, 409);",
|
|
213
|
+
" assert.ok(r.error.length > 0);",
|
|
214
|
+
" }",
|
|
215
|
+
"});",
|
|
216
|
+
"",
|
|
217
|
+
"// TODO: once you've run `npm install && npx prisma migrate dev` you can",
|
|
218
|
+
"// import { signup } from '../../modules/auth/actions/signup.server.ts'",
|
|
219
|
+
"// and { currentUser } from '../../modules/auth/queries/current-user.server.ts'",
|
|
220
|
+
"// and write real integration tests against a test SQLite DB.",
|
|
221
|
+
"",
|
|
222
|
+
].join('\n'));
|
|
223
|
+
|
|
224
|
+
// app/api/auth/[...path]/route.ts
|
|
225
|
+
await mkdir(join(appDir, 'app', 'api', 'auth', '[...path]'), { recursive: true });
|
|
226
|
+
await writeFile(join(appDir, 'app', 'api', 'auth', '[...path]', 'route.ts'), [
|
|
227
|
+
"import { handlers } from '../../../../../lib/auth.server.ts';",
|
|
228
|
+
"export const GET = handlers.GET;",
|
|
229
|
+
"export const POST = handlers.POST;",
|
|
230
|
+
"",
|
|
231
|
+
].join('\n'));
|
|
232
|
+
|
|
233
|
+
// app/login/page.ts
|
|
234
|
+
await mkdir(join(appDir, 'app', 'login'), { recursive: true });
|
|
235
|
+
await writeFile(join(appDir, 'app', 'login', 'page.ts'), [
|
|
236
|
+
"import { html } from '@webjsdev/core';",
|
|
237
|
+
"import { cardClass, cardHeaderClass, cardTitleClass, cardDescriptionClass, cardContentClass, cardFooterClass } from '../../components/ui/card.ts';",
|
|
238
|
+
"import { buttonClass } from '../../components/ui/button.ts';",
|
|
239
|
+
"import { inputClass } from '../../components/ui/input.ts';",
|
|
240
|
+
"import { labelClass } from '../../components/ui/label.ts';",
|
|
241
|
+
"",
|
|
242
|
+
"export const metadata = { title: 'Login' };",
|
|
243
|
+
"",
|
|
244
|
+
"export default function LoginPage() {",
|
|
245
|
+
" return html`",
|
|
246
|
+
" <div class=\"max-w-sm mx-auto mt-12\">",
|
|
247
|
+
" <div class=${cardClass()}>",
|
|
248
|
+
" <div class=${cardHeaderClass()}>",
|
|
249
|
+
" <h3 class=${cardTitleClass()}>Sign in</h3>",
|
|
250
|
+
" <p class=${cardDescriptionClass()}>Welcome back: log in to continue.</p>",
|
|
251
|
+
" </div>",
|
|
252
|
+
" <div class=${cardContentClass()}>",
|
|
253
|
+
" <form method=\"POST\" action=\"/api/auth/callback/credentials\" class=\"flex flex-col gap-4\">",
|
|
254
|
+
" <div class=\"flex flex-col gap-1.5\">",
|
|
255
|
+
" <label class=${labelClass()} for=\"email\">Email</label>",
|
|
256
|
+
" <input class=${inputClass()} id=\"email\" name=\"email\" type=\"email\" required>",
|
|
257
|
+
" </div>",
|
|
258
|
+
" <div class=\"flex flex-col gap-1.5\">",
|
|
259
|
+
" <label class=${labelClass()} for=\"password\">Password</label>",
|
|
260
|
+
" <input class=${inputClass()} id=\"password\" name=\"password\" type=\"password\" required>",
|
|
261
|
+
" </div>",
|
|
262
|
+
" <button class=${buttonClass()} type=\"submit\">Sign in</button>",
|
|
263
|
+
" </form>",
|
|
264
|
+
" </div>",
|
|
265
|
+
" <div class=${cardFooterClass()}>",
|
|
266
|
+
" <p class=\"text-sm text-muted-foreground\">Don't have an account? <a href=\"/signup\" class=\"underline\">Sign up</a></p>",
|
|
267
|
+
" </div>",
|
|
268
|
+
" </div>",
|
|
269
|
+
" </div>",
|
|
270
|
+
" `;",
|
|
271
|
+
"}",
|
|
272
|
+
"",
|
|
273
|
+
].join('\n'));
|
|
274
|
+
|
|
275
|
+
// app/signup/page.ts
|
|
276
|
+
await mkdir(join(appDir, 'app', 'signup'), { recursive: true });
|
|
277
|
+
await writeFile(join(appDir, 'app', 'signup', 'page.ts'), [
|
|
278
|
+
"import { html } from '@webjsdev/core';",
|
|
279
|
+
"import { cardClass, cardHeaderClass, cardTitleClass, cardDescriptionClass, cardContentClass, cardFooterClass } from '../../components/ui/card.ts';",
|
|
280
|
+
"import { buttonClass } from '../../components/ui/button.ts';",
|
|
281
|
+
"import { inputClass } from '../../components/ui/input.ts';",
|
|
282
|
+
"import { labelClass } from '../../components/ui/label.ts';",
|
|
283
|
+
"",
|
|
284
|
+
"export const metadata = { title: 'Sign up' };",
|
|
285
|
+
"",
|
|
286
|
+
"export default function SignupPage() {",
|
|
287
|
+
" return html`",
|
|
288
|
+
" <div class=\"max-w-sm mx-auto mt-12\">",
|
|
289
|
+
" <div class=${cardClass()}>",
|
|
290
|
+
" <div class=${cardHeaderClass()}>",
|
|
291
|
+
" <h3 class=${cardTitleClass()}>Create an account</h3>",
|
|
292
|
+
" <p class=${cardDescriptionClass()}>Get started with your new workspace.</p>",
|
|
293
|
+
" </div>",
|
|
294
|
+
" <div class=${cardContentClass()}>",
|
|
295
|
+
" <form id=\"signup-form\" class=\"flex flex-col gap-4\">",
|
|
296
|
+
" <div class=\"flex flex-col gap-1.5\">",
|
|
297
|
+
" <label class=${labelClass()} for=\"name\">Name</label>",
|
|
298
|
+
" <input class=${inputClass()} id=\"name\" name=\"name\" type=\"text\" required>",
|
|
299
|
+
" </div>",
|
|
300
|
+
" <div class=\"flex flex-col gap-1.5\">",
|
|
301
|
+
" <label class=${labelClass()} for=\"email\">Email</label>",
|
|
302
|
+
" <input class=${inputClass()} id=\"email\" name=\"email\" type=\"email\" required>",
|
|
303
|
+
" </div>",
|
|
304
|
+
" <div class=\"flex flex-col gap-1.5\">",
|
|
305
|
+
" <label class=${labelClass()} for=\"password\">Password</label>",
|
|
306
|
+
" <input class=${inputClass()} id=\"password\" name=\"password\" type=\"password\" required>",
|
|
307
|
+
" </div>",
|
|
308
|
+
" <button class=${buttonClass()} type=\"submit\">Create account</button>",
|
|
309
|
+
" </form>",
|
|
310
|
+
" </div>",
|
|
311
|
+
" <div class=${cardFooterClass()}>",
|
|
312
|
+
" <p class=\"text-sm text-muted-foreground\">Already have an account? <a href=\"/login\" class=\"underline\">Log in</a></p>",
|
|
313
|
+
" </div>",
|
|
314
|
+
" </div>",
|
|
315
|
+
" </div>",
|
|
316
|
+
" `;",
|
|
317
|
+
"}",
|
|
318
|
+
"",
|
|
319
|
+
].join('\n'));
|
|
320
|
+
|
|
321
|
+
// app/dashboard/middleware.ts
|
|
322
|
+
await mkdir(join(appDir, 'app', 'dashboard', 'settings'), { recursive: true });
|
|
323
|
+
await writeFile(join(appDir, 'app', 'dashboard', 'middleware.ts'), [
|
|
324
|
+
"import { auth } from '../../lib/auth.server.ts';",
|
|
325
|
+
"",
|
|
326
|
+
"export default async function requireAuth(req: Request, next: () => Promise<Response>) {",
|
|
327
|
+
" const session = await auth();",
|
|
328
|
+
" if (!session?.user) {",
|
|
329
|
+
" return new Response(null, { status: 302, headers: { location: '/login' } });",
|
|
330
|
+
" }",
|
|
331
|
+
" return next();",
|
|
332
|
+
"}",
|
|
333
|
+
"",
|
|
334
|
+
].join('\n'));
|
|
335
|
+
|
|
336
|
+
// app/dashboard/page.ts
|
|
337
|
+
await writeFile(join(appDir, 'app', 'dashboard', 'page.ts'), [
|
|
338
|
+
"import { html } from '@webjsdev/core';",
|
|
339
|
+
"import { currentUser } from '../../modules/auth/queries/current-user.server.ts';",
|
|
340
|
+
"import { cardClass, cardHeaderClass, cardTitleClass, cardDescriptionClass, cardContentClass } from '../../components/ui/card.ts';",
|
|
341
|
+
"import { buttonClass } from '../../components/ui/button.ts';",
|
|
342
|
+
"import { badgeClass } from '../../components/ui/badge.ts';",
|
|
343
|
+
"",
|
|
344
|
+
"export const metadata = { title: 'Dashboard' };",
|
|
345
|
+
"",
|
|
346
|
+
"export default async function Dashboard() {",
|
|
347
|
+
" const user = await currentUser();",
|
|
348
|
+
" return html`",
|
|
349
|
+
" <div class=\"flex items-center justify-between mb-6\">",
|
|
350
|
+
" <h1 class=\"text-2xl font-semibold\">Dashboard</h1>",
|
|
351
|
+
" <span class=${badgeClass({ variant: 'secondary' })}>Signed in</span>",
|
|
352
|
+
" </div>",
|
|
353
|
+
" <div class=${cardClass()}>",
|
|
354
|
+
" <div class=${cardHeaderClass()}>",
|
|
355
|
+
" <h3 class=${cardTitleClass()}>Welcome, ${`\\$\\{user?.name || user?.email\\}`}!</h3>",
|
|
356
|
+
" <p class=${cardDescriptionClass()}>You're authenticated. Replace this scaffold with your real app.</p>",
|
|
357
|
+
" </div>",
|
|
358
|
+
" <div class=${cardContentClass()}>",
|
|
359
|
+
" <a class=${buttonClass({ variant: 'outline' })} href=\"/dashboard/settings\">Settings</a>",
|
|
360
|
+
" </div>",
|
|
361
|
+
" </div>",
|
|
362
|
+
" `;",
|
|
363
|
+
"}",
|
|
364
|
+
"",
|
|
365
|
+
].join('\n'));
|
|
366
|
+
|
|
367
|
+
// app/dashboard/settings/page.ts
|
|
368
|
+
await writeFile(join(appDir, 'app', 'dashboard', 'settings', 'page.ts'), [
|
|
369
|
+
"import { html } from '@webjsdev/core';",
|
|
370
|
+
"import { currentUser } from '../../../modules/auth/queries/current-user.server.ts';",
|
|
371
|
+
"import { cardClass, cardHeaderClass, cardTitleClass, cardDescriptionClass, cardContentClass } from '../../../components/ui/card.ts';",
|
|
372
|
+
"",
|
|
373
|
+
"export const metadata = { title: 'Settings' };",
|
|
374
|
+
"",
|
|
375
|
+
"export default async function Settings() {",
|
|
376
|
+
" const user = await currentUser();",
|
|
377
|
+
" return html`",
|
|
378
|
+
" <h1 class=\"text-2xl font-semibold mb-6\">Settings</h1>",
|
|
379
|
+
" <div class=${cardClass()}>",
|
|
380
|
+
" <div class=${cardHeaderClass()}>",
|
|
381
|
+
" <h3 class=${cardTitleClass()}>Account</h3>",
|
|
382
|
+
" <p class=${cardDescriptionClass()}>Your basic profile information.</p>",
|
|
383
|
+
" </div>",
|
|
384
|
+
" <div class=${cardContentClass()}>",
|
|
385
|
+
" <dl class=\"grid grid-cols-[max-content_1fr] gap-x-6 gap-y-2 text-sm\">",
|
|
386
|
+
" <dt class=\"text-muted-foreground\">Email</dt>",
|
|
387
|
+
" <dd>${`\\$\\{user?.email\\}`}</dd>",
|
|
388
|
+
" <dt class=\"text-muted-foreground\">Name</dt>",
|
|
389
|
+
" <dd>${`\\$\\{user?.name || 'Not set'\\}`}</dd>",
|
|
390
|
+
" </dl>",
|
|
391
|
+
" </div>",
|
|
392
|
+
" </div>",
|
|
393
|
+
" `;",
|
|
394
|
+
"}",
|
|
395
|
+
"",
|
|
396
|
+
].join('\n'));
|
|
397
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@webjsdev/cli",
|
|
3
|
+
"version": "0.8.1",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "webjs CLI - dev, start, create, db",
|
|
6
|
+
"bin": {
|
|
7
|
+
"webjs": "bin/webjs.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"bin",
|
|
11
|
+
"lib",
|
|
12
|
+
"templates",
|
|
13
|
+
"README.md"
|
|
14
|
+
],
|
|
15
|
+
"dependencies": {
|
|
16
|
+
"@webjsdev/server": "^0.7.2",
|
|
17
|
+
"@webjsdev/ui": "^0.3.1"
|
|
18
|
+
},
|
|
19
|
+
"publishConfig": {
|
|
20
|
+
"access": "public"
|
|
21
|
+
},
|
|
22
|
+
"repository": {
|
|
23
|
+
"type": "git",
|
|
24
|
+
"url": "git+https://github.com/vivek7405/webjs.git",
|
|
25
|
+
"directory": "packages/cli"
|
|
26
|
+
},
|
|
27
|
+
"homepage": "https://github.com/vivek7405/webjs#readme",
|
|
28
|
+
"bugs": "https://github.com/vivek7405/webjs/issues",
|
|
29
|
+
"license": "MIT",
|
|
30
|
+
"keywords": [
|
|
31
|
+
"webjs",
|
|
32
|
+
"cli",
|
|
33
|
+
"dev",
|
|
34
|
+
"scaffold"
|
|
35
|
+
],
|
|
36
|
+
"engines": {
|
|
37
|
+
"node": ">=24.0.0"
|
|
38
|
+
}
|
|
39
|
+
}
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
#
|
|
3
|
+
# PreToolUse hook: block prose-punctuation patterns the webjs convention bans.
|
|
4
|
+
#
|
|
5
|
+
# Catches four classes of new content in tool calls:
|
|
6
|
+
#
|
|
7
|
+
# 1. U+2014 em-dash, anywhere.
|
|
8
|
+
# 2. Space-hyphen-space " - " in PROSE contexts (comment lines, markdown
|
|
9
|
+
# lines, headings, blockquotes). Math expressions in code like
|
|
10
|
+
# `Math.abs(a - b)` or `arr.length - 1` are NOT flagged.
|
|
11
|
+
# 3. Space-semicolon-space " ; " in PROSE contexts. JS / CSS statement
|
|
12
|
+
# terminators (`;\n`) are NOT flagged.
|
|
13
|
+
# 4. Code-shaped left-hand side immediately followed by a colon and prose:
|
|
14
|
+
# - `<code>foo()</code>:` (markdown code-LHS in docs)
|
|
15
|
+
# - `<my-tag>:` (custom-element tag with hyphen)
|
|
16
|
+
# - Inline comment `// foo(): description`
|
|
17
|
+
#
|
|
18
|
+
# Why this exists: see AGENTS.md "Invariants", item 10. These patterns
|
|
19
|
+
# confuse AI agents that try to parse the prose as TypeScript / shorthand-
|
|
20
|
+
# method / object-literal syntax, and trip humans reading API docs.
|
|
21
|
+
#
|
|
22
|
+
# Covers two tool-call paths:
|
|
23
|
+
# * Write / Edit / MultiEdit / NotebookEdit. The hook inspects the NEW
|
|
24
|
+
# content fields of the tool payload. Existing glyphs in old_string
|
|
25
|
+
# are not flagged: you can still Edit a line that contains one to
|
|
26
|
+
# remove it.
|
|
27
|
+
# * Bash. The hook inspects the command string, which catches commit
|
|
28
|
+
# messages (`git commit -m "..."`), heredocs, echo / printf, and any
|
|
29
|
+
# other prose typed at the shell.
|
|
30
|
+
|
|
31
|
+
set -euo pipefail
|
|
32
|
+
|
|
33
|
+
payload=$(cat)
|
|
34
|
+
|
|
35
|
+
# Pull every field where prose might land. `// empty` keeps missing
|
|
36
|
+
# fields silent; `[]?` keeps array iteration safe when absent.
|
|
37
|
+
new_content=$(printf '%s' "$payload" | jq -r '
|
|
38
|
+
(.tool_input.content // empty),
|
|
39
|
+
(.tool_input.new_string // empty),
|
|
40
|
+
(.tool_input.new_source // empty),
|
|
41
|
+
(.tool_input.command // empty),
|
|
42
|
+
(.tool_input.edits[]?.new_string // empty)
|
|
43
|
+
' 2>/dev/null || true)
|
|
44
|
+
|
|
45
|
+
if [ -z "$new_content" ]; then
|
|
46
|
+
exit 0
|
|
47
|
+
fi
|
|
48
|
+
|
|
49
|
+
# --- 1. U+2014 em-dash --------------------------------------------------
|
|
50
|
+
if printf '%s' "$new_content" | grep -q $'\xe2\x80\x94'; then
|
|
51
|
+
cat >&2 <<'EOF'
|
|
52
|
+
BLOCKED: em-dash (U+2014) detected in this tool call.
|
|
53
|
+
|
|
54
|
+
webjs bans em-dashes repo-wide. Replace every U+2014 character with
|
|
55
|
+
a period, comma, colon (on a plain-noun LHS), parentheses, or
|
|
56
|
+
restructured sentence. Do NOT replace it with " - " or " ; " or a
|
|
57
|
+
trailing colon on code: those are also banned. See rule 2 / 3 / 4
|
|
58
|
+
below for the alternatives.
|
|
59
|
+
|
|
60
|
+
Rule: AGENTS.md, Invariants section, item 10.
|
|
61
|
+
Hook: .claude/hooks/block-prose-punctuation.sh.
|
|
62
|
+
EOF
|
|
63
|
+
exit 2
|
|
64
|
+
fi
|
|
65
|
+
|
|
66
|
+
# --- 2. Pause-hyphen " - " in PROSE contexts ----------------------------
|
|
67
|
+
# Only flag lines whose context is clearly prose:
|
|
68
|
+
# - Markdown lines starting with `#`, `>`, `*`, plain text outside code
|
|
69
|
+
# fences (heuristic: line has no `=`, `{`, or `(...)` math)
|
|
70
|
+
# - JSDoc / block comment lines starting with `*`
|
|
71
|
+
# - Single-line comments starting with `//`
|
|
72
|
+
#
|
|
73
|
+
# Math expressions like `Math.abs(a - b)` or `arr.length - 1` are NOT
|
|
74
|
+
# flagged because they appear in code lines (not comments) with code
|
|
75
|
+
# context. The hook trades some false negatives in prose for zero false
|
|
76
|
+
# positives in code-heavy diffs.
|
|
77
|
+
|
|
78
|
+
block_pause_hyphen=0
|
|
79
|
+
|
|
80
|
+
# Comment-line " - " pause: line starts with `//` or ` *` (JSDoc/block) or
|
|
81
|
+
# `*` (markdown bold-start would have a letter after, distinguishable),
|
|
82
|
+
# followed by prose with `\w+ - \w+` pattern. Specifically: catch lines
|
|
83
|
+
# like `// foo - bar`, ` * foo - bar`, `* foo - bar`.
|
|
84
|
+
if printf '%s\n' "$new_content" | grep -qE '^[[:space:]]*(//|\*)[[:space:]].*[A-Za-z`)>][[:space:]]-[[:space:]][A-Za-z`(<]'; then
|
|
85
|
+
block_pause_hyphen=1
|
|
86
|
+
fi
|
|
87
|
+
|
|
88
|
+
# Markdown heading " - " pause: line starts with `#` followed by prose
|
|
89
|
+
# and ` - ` pattern.
|
|
90
|
+
if printf '%s\n' "$new_content" | grep -qE '^#{1,6}[[:space:]].*[A-Za-z`)>][[:space:]]-[[:space:]][A-Za-z`(<]'; then
|
|
91
|
+
block_pause_hyphen=1
|
|
92
|
+
fi
|
|
93
|
+
|
|
94
|
+
# Markdown blockquote " - " pause: line starts with `>` followed by prose
|
|
95
|
+
# and ` - ` pattern. (Single `>` blockquote, not table.)
|
|
96
|
+
if printf '%s\n' "$new_content" | grep -qE '^>[[:space:]].*[A-Za-z`)>][[:space:]]-[[:space:]][A-Za-z`(<]'; then
|
|
97
|
+
block_pause_hyphen=1
|
|
98
|
+
fi
|
|
99
|
+
|
|
100
|
+
# HTML / markdown <p>, <li>, <td> body " - " pause: line contains a
|
|
101
|
+
# closing HTML tag from a prose context, then prose-style ` - `.
|
|
102
|
+
if printf '%s\n' "$new_content" | grep -qE '<(p|li|td|h[1-6]|strong|em|blockquote)[^>]*>[^<]*[A-Za-z`)>][[:space:]]-[[:space:]][A-Za-z`(<]'; then
|
|
103
|
+
block_pause_hyphen=1
|
|
104
|
+
fi
|
|
105
|
+
|
|
106
|
+
if [ "$block_pause_hyphen" = "1" ]; then
|
|
107
|
+
cat >&2 <<'EOF'
|
|
108
|
+
BLOCKED: pause-hyphen " - " detected in a prose context.
|
|
109
|
+
|
|
110
|
+
webjs bans plain hyphens used as pause-punctuation in prose. Rewrite
|
|
111
|
+
the sentence with a period, comma, colon (on a plain-noun LHS), or
|
|
112
|
+
restructured phrasing.
|
|
113
|
+
|
|
114
|
+
Bad: // Foo - bar
|
|
115
|
+
Good: // Foo, with bar
|
|
116
|
+
Good: // Foo. Bar.
|
|
117
|
+
|
|
118
|
+
Bad: <li>Foo - bar.</li>
|
|
119
|
+
Good: <li>Foo, with bar.</li>
|
|
120
|
+
|
|
121
|
+
Plain hyphens are still fine in compound words (`AI-first`), CLI
|
|
122
|
+
flags (`--http2`), filenames, ranges, and math expressions in code
|
|
123
|
+
(`arr.length - 1`, `Math.abs(a - b)`). The hook only flags the
|
|
124
|
+
` < word > - < word > ` pause-pattern in prose contexts (comments,
|
|
125
|
+
markdown headings, blockquotes, HTML prose tags).
|
|
126
|
+
|
|
127
|
+
Rule: AGENTS.md, Invariants section, item 10.
|
|
128
|
+
Hook: .claude/hooks/block-prose-punctuation.sh.
|
|
129
|
+
EOF
|
|
130
|
+
exit 2
|
|
131
|
+
fi
|
|
132
|
+
|
|
133
|
+
# --- 3. Pause-semicolon " ; " in PROSE contexts -------------------------
|
|
134
|
+
# Same prose-context guard as #2.
|
|
135
|
+
block_pause_semicolon=0
|
|
136
|
+
|
|
137
|
+
if printf '%s\n' "$new_content" | grep -qE '^[[:space:]]*(//|\*)[[:space:]].*[A-Za-z`)][[:space:]];[[:space:]][A-Za-z`(]'; then
|
|
138
|
+
block_pause_semicolon=1
|
|
139
|
+
fi
|
|
140
|
+
|
|
141
|
+
if printf '%s\n' "$new_content" | grep -qE '^#{1,6}[[:space:]].*[A-Za-z`)][[:space:]];[[:space:]][A-Za-z`(]'; then
|
|
142
|
+
block_pause_semicolon=1
|
|
143
|
+
fi
|
|
144
|
+
|
|
145
|
+
if printf '%s\n' "$new_content" | grep -qE '^>[[:space:]].*[A-Za-z`)][[:space:]];[[:space:]][A-Za-z`(]'; then
|
|
146
|
+
block_pause_semicolon=1
|
|
147
|
+
fi
|
|
148
|
+
|
|
149
|
+
if printf '%s\n' "$new_content" | grep -qE '<(p|li|td|h[1-6]|strong|em|blockquote)[^>]*>[^<]*[A-Za-z`)][[:space:]];[[:space:]][A-Za-z`(]'; then
|
|
150
|
+
block_pause_semicolon=1
|
|
151
|
+
fi
|
|
152
|
+
|
|
153
|
+
if [ "$block_pause_semicolon" = "1" ]; then
|
|
154
|
+
cat >&2 <<'EOF'
|
|
155
|
+
BLOCKED: pause-semicolon " ; " detected in a prose context.
|
|
156
|
+
|
|
157
|
+
webjs bans semicolons used as pause-punctuation in prose. Rewrite as
|
|
158
|
+
two sentences (period) or with a conjunction (", and", ", but", ", so").
|
|
159
|
+
|
|
160
|
+
Bad: // Forms work ; links work too.
|
|
161
|
+
Good: // Forms work. Links work too.
|
|
162
|
+
Good: // Forms work, and links work too.
|
|
163
|
+
|
|
164
|
+
Semicolons stay fine inside code (JS statement terminators, CSS
|
|
165
|
+
declarations) since those are not flagged.
|
|
166
|
+
|
|
167
|
+
Rule: AGENTS.md, Invariants section, item 10.
|
|
168
|
+
Hook: .claude/hooks/block-prose-punctuation.sh.
|
|
169
|
+
EOF
|
|
170
|
+
exit 2
|
|
171
|
+
fi
|
|
172
|
+
|
|
173
|
+
# --- 4a. <code>foo()</code>: prose ---------------------------------------
|
|
174
|
+
# Markdown / HTML definition list with code-call followed by colon and
|
|
175
|
+
# lowercase prose. The `)</code>:` shape is unambiguous: this is markdown,
|
|
176
|
+
# not code, AND the inner code ends in `()` so the colon visually parses
|
|
177
|
+
# as a return-type annotation.
|
|
178
|
+
if printf '%s' "$new_content" | grep -qE '\)</code>:[[:space:]][a-z]'; then
|
|
179
|
+
cat >&2 <<'EOF'
|
|
180
|
+
BLOCKED: code-LHS colon-then-prose detected ("<code>foo()</code>: ...").
|
|
181
|
+
|
|
182
|
+
webjs bans `<code>foo()</code>: <prose>` because the colon visually
|
|
183
|
+
parses as a TypeScript return-type annotation. Rewrite verb-led.
|
|
184
|
+
|
|
185
|
+
Bad: <code>repeat()</code>: keyed list directive
|
|
186
|
+
Good: <code>repeat()</code> is the keyed list directive
|
|
187
|
+
Good: <code>startServer()</code> creates an HTTP(S) server
|
|
188
|
+
|
|
189
|
+
Rule: AGENTS.md, Invariants section, item 10.
|
|
190
|
+
Hook: .claude/hooks/block-prose-punctuation.sh.
|
|
191
|
+
EOF
|
|
192
|
+
exit 2
|
|
193
|
+
fi
|
|
194
|
+
|
|
195
|
+
# --- 4b. Custom-element-tag <my-tag>: prose ------------------------------
|
|
196
|
+
# HTML reserves hyphenated tag names for custom elements (W3C spec), so
|
|
197
|
+
# `<x-y>:` is unambiguous prose, never JSX / TS / CSS.
|
|
198
|
+
if printf '%s' "$new_content" | grep -qE '<[a-z][a-z0-9]*(-[a-z0-9]+)+([[:space:]][^>]*)?>:[[:space:]][a-z]'; then
|
|
199
|
+
cat >&2 <<'EOF'
|
|
200
|
+
BLOCKED: custom-element-tag colon-then-prose detected ("<my-tag>: ...").
|
|
201
|
+
|
|
202
|
+
webjs bans `<my-tag>: <prose>` in comments and docs. Rewrite verb-led.
|
|
203
|
+
|
|
204
|
+
Bad: // <ui-dialog>: owns open state, focus trap, escape, scroll lock.
|
|
205
|
+
Good: // <ui-dialog> owns open state, focus trap, escape, scroll lock.
|
|
206
|
+
Bad: // <ui-dialog-content>: the centered panel.
|
|
207
|
+
Good: // <ui-dialog-content> is the centered panel.
|
|
208
|
+
|
|
209
|
+
Rule: AGENTS.md, Invariants section, item 10.
|
|
210
|
+
Hook: .claude/hooks/block-prose-punctuation.sh.
|
|
211
|
+
EOF
|
|
212
|
+
exit 2
|
|
213
|
+
fi
|
|
214
|
+
|
|
215
|
+
# --- 4c. Inline / JSDoc comment "foo(): prose" --------------------------
|
|
216
|
+
# Match comment-line prefix (`//` or leading `*`) before `\w+(...): ` and
|
|
217
|
+
# lowercase prose. Avoids TS return-type annotations because those never
|
|
218
|
+
# appear inside comment lines.
|
|
219
|
+
if printf '%s\n' "$new_content" | grep -qE '^[[:space:]]*(//|\*)[[:space:]][^(]*[A-Za-z_][A-Za-z0-9_]*\([^)]*\):[[:space:]][a-z]'; then
|
|
220
|
+
cat >&2 <<'EOF'
|
|
221
|
+
BLOCKED: comment-line code-LHS colon-then-prose detected ("// foo(): ...").
|
|
222
|
+
|
|
223
|
+
webjs bans `xyz(): <prose>` inside comments and JSDoc. Rewrite verb-led.
|
|
224
|
+
|
|
225
|
+
Bad: // firstUpdated(): once, on the first render only
|
|
226
|
+
Good: // firstUpdated() runs once, on the first render only
|
|
227
|
+
Bad: // closest(): null if the click wasn't inside a frame
|
|
228
|
+
Good: // closest() returns null when the click wasn't inside a frame
|
|
229
|
+
|
|
230
|
+
Rule: AGENTS.md, Invariants section, item 10.
|
|
231
|
+
Hook: .claude/hooks/block-prose-punctuation.sh.
|
|
232
|
+
EOF
|
|
233
|
+
exit 2
|
|
234
|
+
fi
|
|
235
|
+
|
|
236
|
+
exit 0
|