@webjskit/cli 0.1.0

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/lib/create.js ADDED
@@ -0,0 +1,519 @@
1
+ /**
2
+ * `webjs create <name>` — scaffold a new webjs app with opinionated defaults.
3
+ *
4
+ * Creates a directory with:
5
+ * - app/ with a root layout + page
6
+ * - modules/ skeleton
7
+ * - components/ with a theme toggle
8
+ * - test/unit/ and test/e2e/ with example tests
9
+ * - CONVENTIONS.md, AGENTS.md, CLAUDE.md
10
+ * - package.json with webjs deps + test scripts
11
+ * - tsconfig.json for editor support
12
+ */
13
+
14
+ import { mkdir, writeFile, readFile, cp } from 'node:fs/promises';
15
+ import { join, resolve, dirname } from 'node:path';
16
+ import { fileURLToPath } from 'node:url';
17
+ import { existsSync } from 'node:fs';
18
+
19
+ const __dirname = dirname(fileURLToPath(import.meta.url));
20
+ const TEMPLATES = resolve(__dirname, '..', 'templates');
21
+
22
+ /**
23
+ * @param {string} name App directory name
24
+ * @param {string} cwd Current working directory
25
+ */
26
+ export async function scaffoldApp(name, cwd, opts = {}) {
27
+ const template = opts.template || 'full-stack';
28
+ const isApi = template === 'api';
29
+ const isSaas = template === 'saas';
30
+ const appDir = join(cwd, name);
31
+ if (existsSync(appDir)) {
32
+ console.error(`Error: directory '${name}' already exists.`);
33
+ process.exit(1);
34
+ }
35
+
36
+ console.log(`\nwebjs create: scaffolding '${name}' (${template})...\n`);
37
+
38
+ // Create directory structure
39
+ const dirs = [
40
+ 'app',
41
+ 'components',
42
+ 'modules',
43
+ 'lib',
44
+ 'public',
45
+ 'test/unit',
46
+ 'test/e2e',
47
+ ];
48
+ for (const d of dirs) await mkdir(join(appDir, d), { recursive: true });
49
+
50
+ // --- Root files ---
51
+
52
+ await writeFile(join(appDir, 'package.json'), JSON.stringify({
53
+ name,
54
+ version: '0.1.0',
55
+ type: 'module',
56
+ private: true,
57
+ scripts: {
58
+ dev: 'webjs dev',
59
+ build: 'webjs build',
60
+ start: 'webjs start',
61
+ test: 'webjs test',
62
+ 'test:server': 'webjs test --server',
63
+ 'test:browser': 'webjs test --browser',
64
+ check: 'webjs check',
65
+ },
66
+ dependencies: {
67
+ '@webjskit/cli': 'latest',
68
+ '@webjskit/core': 'latest',
69
+ '@webjskit/server': 'latest',
70
+ ...(isSaas ? { '@prisma/client': '^6.0.0' } : {}),
71
+ },
72
+ devDependencies: {
73
+ esbuild: '^0.28.0',
74
+ '@web/test-runner': '^0.20.0',
75
+ '@web/test-runner-playwright': '^0.11.0',
76
+ 'playwright': '^1.59.0',
77
+ ...(isSaas ? { prisma: '^6.0.0' } : {}),
78
+ },
79
+ }, null, 2) + '\n');
80
+
81
+ await writeFile(join(appDir, 'tsconfig.json'), JSON.stringify({
82
+ compilerOptions: {
83
+ target: 'ES2022',
84
+ module: 'NodeNext',
85
+ moduleResolution: 'NodeNext',
86
+ lib: ['ES2022', 'DOM', 'DOM.Iterable'],
87
+ strict: true,
88
+ noEmit: true,
89
+ allowImportingTsExtensions: true,
90
+ skipLibCheck: true,
91
+ // ts-lit-plugin gives tag/attribute intelligence inside html`` templates
92
+ // (autocomplete, type-check, go-to-definition for <my-element>).
93
+ // Install: `npm i -D ts-lit-plugin`. Remove this plugin entry if you don't want it.
94
+ plugins: [{ name: 'ts-lit-plugin', strict: true }],
95
+ },
96
+ }, null, 2) + '\n');
97
+
98
+ // --- Templates (CONVENTIONS.md, CLAUDE.md, test files, Claude hooks) ---
99
+
100
+ const templateFiles = [
101
+ 'CONVENTIONS.md',
102
+ 'CLAUDE.md',
103
+ 'test/unit/example.test.ts',
104
+ 'test/browser/example.test.js',
105
+ 'web-test-runner.config.js',
106
+ // Environment variables
107
+ '.env.example',
108
+ // Git hooks (blocks commits on main)
109
+ '.hooks/pre-commit',
110
+ // Claude Code config + hooks
111
+ '.claude.json',
112
+ '.claude/settings.json',
113
+ '.claude/hooks/guard-main-merge.sh',
114
+ '.claude/hooks/guard-branch-context.sh',
115
+ // Cross-agent config files
116
+ '.cursorrules',
117
+ '.windsurfrules',
118
+ '.github/copilot-instructions.md',
119
+ '.github/pull_request_template.md',
120
+ '.editorconfig',
121
+ ];
122
+ for (const f of templateFiles) {
123
+ const src = join(TEMPLATES, f);
124
+ if (existsSync(src)) {
125
+ await mkdir(dirname(join(appDir, f)), { recursive: true });
126
+ let content = await readFile(src, 'utf8');
127
+ content = content.replace(/\{\{APP_NAME\}\}/g, name);
128
+ await writeFile(join(appDir, f), content);
129
+ }
130
+ }
131
+
132
+ // Make hook scripts executable
133
+ const { chmod } = await import('node:fs/promises');
134
+ for (const hook of ['guard-main-merge.sh', 'guard-branch-context.sh']) {
135
+ const hookPath = join(appDir, '.claude', 'hooks', hook);
136
+ if (existsSync(hookPath)) await chmod(hookPath, 0o755);
137
+ }
138
+ // Make git pre-commit hook executable
139
+ const preCommitPath = join(appDir, '.hooks', 'pre-commit');
140
+ if (existsSync(preCommitPath)) await chmod(preCommitPath, 0o755);
141
+
142
+ // --- App files (template-specific) ---
143
+
144
+ if (isApi) {
145
+ // API-only template: no layout, no page, no components.
146
+ // Just a health route and an example module with route wrapper.
147
+ await mkdir(join(appDir, 'app', 'api', 'health'), { recursive: true });
148
+ await mkdir(join(appDir, 'app', 'api', 'users'), { recursive: true });
149
+ await writeFile(join(appDir, 'app', 'api', 'health', 'route.ts'), `export async function GET() {
150
+ return Response.json({ status: 'ok', timestamp: Date.now() });
151
+ }
152
+ `);
153
+ await mkdir(join(appDir, 'modules', 'users', 'actions'), { recursive: true });
154
+ await mkdir(join(appDir, 'modules', 'users', 'queries'), { recursive: true });
155
+
156
+ await writeFile(join(appDir, 'modules', 'users', 'queries', 'list-users.server.ts'), `'use server';
157
+
158
+ export async function listUsers() {
159
+ // TODO: replace with real data source
160
+ return [
161
+ { id: '1', name: 'Alice', email: 'alice@example.com' },
162
+ { id: '2', name: 'Bob', email: 'bob@example.com' },
163
+ ];
164
+ }
165
+ `);
166
+ await writeFile(join(appDir, 'modules', 'users', 'actions', 'create-user.server.ts'), `'use server';
167
+
168
+ export async function createUser(input: { name: string; email: string }) {
169
+ // TODO: validate input, persist to database
170
+ return { success: true, data: { id: Date.now().toString(), ...input } };
171
+ }
172
+ `);
173
+ await writeFile(join(appDir, 'app', 'api', 'users', 'route.ts'), `/**
174
+ * /api/users — thin route wrapper over typed server actions.
175
+ * Business logic lives in modules/users/, not here.
176
+ */
177
+ import { listUsers } from '../../../../modules/users/queries/list-users.server.ts';
178
+ import { createUser } from '../../../../modules/users/actions/create-user.server.ts';
179
+
180
+ export async function GET() {
181
+ return Response.json(await listUsers());
182
+ }
183
+
184
+ export async function POST(req: Request) {
185
+ const body = await req.json();
186
+ return Response.json(await createUser(body));
187
+ }
188
+ `);
189
+ await writeFile(join(appDir, 'modules', 'users', 'types.ts'), `export interface User {
190
+ id: string;
191
+ name: string;
192
+ email: string;
193
+ }
194
+
195
+ export type ActionResult<T> =
196
+ | { success: true; data: T }
197
+ | { success: false; error: string; status: number };
198
+ `);
199
+ }
200
+
201
+ if (!isApi) {
202
+ // Full-stack and SaaS templates: layout + page + theme toggle + Tailwind
203
+
204
+ // Copy the Tailwind browser runtime + _utils/ui.ts helpers from the
205
+ // scaffold templates directory so the app boots with the exact blog
206
+ // example architecture: light DOM + Tailwind + JS helpers.
207
+ const publicDir = join(appDir, 'public');
208
+ await mkdir(publicDir, { recursive: true });
209
+ const tailwindSrc = join(TEMPLATES, 'public', 'tailwind-browser.js');
210
+ if (existsSync(tailwindSrc)) {
211
+ await cp(tailwindSrc, join(publicDir, 'tailwind-browser.js'));
212
+ }
213
+
214
+ const utilsDir = join(appDir, 'app', '_utils');
215
+ await mkdir(utilsDir, { recursive: true });
216
+ const uiSrc = join(TEMPLATES, 'app', '_utils', 'ui.ts');
217
+ if (existsSync(uiSrc)) {
218
+ await cp(uiSrc, join(utilsDir, 'ui.ts'));
219
+ }
220
+
221
+ await writeFile(join(appDir, 'app', 'layout.ts'), `import { html } from '@webjskit/core';
222
+ import '@webjskit/core/client-router';
223
+ import '../components/theme-toggle.ts';
224
+
225
+ /**
226
+ * Root layout — globals + chrome.
227
+ *
228
+ * Light DOM + Tailwind by default. Design tokens live in :root and are
229
+ * mapped into the Tailwind palette via @theme, so classes like
230
+ * text-fg, bg-bg-elev, font-serif, duration-fast, text-display all work.
231
+ *
232
+ * Nav + footer links repeat the same class bundle, so they're extracted
233
+ * into small JS helpers below. Each helper runs at SSR time inside
234
+ * html\\\`\\\`, producing static HTML in the response — no client runtime.
235
+ */
236
+
237
+ const navLink = (href: string, label: string) => html\`
238
+ <a href=\${href} class="text-fg-muted no-underline font-medium text-[13px] leading-none tracking-[0.005em] transition-colors duration-fast hover:text-fg">\${label}</a>
239
+ \`;
240
+
241
+ export default function RootLayout({ children }: { children: unknown }) {
242
+ return html\`
243
+ <script>
244
+ (function(){
245
+ try {
246
+ var t = localStorage.getItem('webjs_theme');
247
+ if (t === 'light' || t === 'dark') {
248
+ document.documentElement.dataset.theme = t;
249
+ }
250
+ } catch (_) {}
251
+ })();
252
+ </script>
253
+ <script src="/public/tailwind-browser.js"></script>
254
+ <style type="text/tailwindcss">
255
+ @theme {
256
+ --color-fg: var(--fg);
257
+ --color-fg-muted: var(--fg-muted);
258
+ --color-fg-subtle: var(--fg-subtle);
259
+ --color-bg: var(--bg);
260
+ --color-bg-elev: var(--bg-elev);
261
+ --color-bg-subtle: var(--bg-subtle);
262
+ --color-border: var(--border);
263
+ --color-border-strong: var(--border-strong);
264
+ --color-accent: var(--accent);
265
+ --color-accent-hover: var(--accent-hover);
266
+ --color-accent-fg: var(--accent-fg);
267
+ --color-accent-tint: var(--accent-tint);
268
+ --font-sans: var(--font-sans);
269
+ --font-serif: var(--font-serif);
270
+ --font-mono: var(--font-mono);
271
+ --text-display: clamp(2.6rem, 1.6rem + 3.2vw, 4.25rem);
272
+ --text-h1: clamp(2rem, 1.5rem + 1.6vw, 2.85rem);
273
+ --text-h2: clamp(1.35rem, 1.15rem + 0.7vw, 1.7rem);
274
+ --text-lede: clamp(1.05rem, 0.95rem + 0.3vw, 1.2rem);
275
+ --duration-fast: 140ms;
276
+ --duration-slow: 380ms;
277
+ }
278
+ </style>
279
+ <style>
280
+ :root {
281
+ color-scheme: light dark;
282
+ /* ---------- dark (default) ---------- */
283
+ --fg: oklch(0.96 0.015 60);
284
+ --fg-muted: oklch(0.72 0.02 60);
285
+ --fg-subtle: oklch(0.55 0.02 60);
286
+ --bg: oklch(0.14 0.01 55);
287
+ --bg-elev: oklch(0.18 0.01 55);
288
+ --bg-subtle: oklch(0.16 0.01 55);
289
+ --border: oklch(0.26 0.012 55 / 0.9);
290
+ --border-strong: oklch(0.38 0.012 55 / 0.9);
291
+ --accent: oklch(0.78 0.14 55);
292
+ --accent-hover: oklch(0.85 0.14 55);
293
+ --accent-fg: oklch(0.15 0.01 55);
294
+ --accent-tint: oklch(0.78 0.14 55 / 0.14);
295
+ --font-sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
296
+ --font-serif: ui-serif, 'Iowan Old Style', Palatino, Georgia, serif;
297
+ --font-mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
298
+ }
299
+ :root[data-theme='light'] {
300
+ --fg: oklch(0.18 0.015 60);
301
+ --fg-muted: oklch(0.42 0.02 65);
302
+ --fg-subtle: oklch(0.62 0.015 70);
303
+ --bg: oklch(0.985 0.008 80);
304
+ --bg-elev: oklch(1 0 0);
305
+ --bg-subtle: oklch(0.96 0.008 80);
306
+ --border: oklch(0.88 0.01 75 / 0.95);
307
+ --border-strong: oklch(0.78 0.01 75 / 0.95);
308
+ --accent: oklch(0.58 0.15 55);
309
+ --accent-hover: oklch(0.5 0.15 55);
310
+ --accent-fg: oklch(1 0 0);
311
+ --accent-tint: oklch(0.58 0.15 55 / 0.1);
312
+ }
313
+ @media (prefers-color-scheme: light) {
314
+ :root:not([data-theme='dark']) {
315
+ --fg: oklch(0.18 0.015 60);
316
+ --fg-muted: oklch(0.42 0.02 65);
317
+ --fg-subtle: oklch(0.62 0.015 70);
318
+ --bg: oklch(0.985 0.008 80);
319
+ --bg-elev: oklch(1 0 0);
320
+ --bg-subtle: oklch(0.96 0.008 80);
321
+ --border: oklch(0.88 0.01 75 / 0.95);
322
+ --border-strong: oklch(0.78 0.01 75 / 0.95);
323
+ --accent: oklch(0.58 0.15 55);
324
+ --accent-hover: oklch(0.5 0.15 55);
325
+ --accent-fg: oklch(1 0 0);
326
+ --accent-tint: oklch(0.58 0.15 55 / 0.1);
327
+ }
328
+ }
329
+ /* Body + pseudo-elements utility classes can't reach. */
330
+ html, body { margin: 0; }
331
+ body {
332
+ background: var(--bg);
333
+ color: var(--fg);
334
+ font: 16px/1.65 var(--font-sans);
335
+ -webkit-font-smoothing: antialiased;
336
+ }
337
+ ::selection { background: var(--accent-tint); color: var(--fg); }
338
+ </style>
339
+
340
+ <header class="sticky top-0 z-20 flex items-center gap-6 px-4 sm:px-6 py-3 border-b border-border bg-[color-mix(in_oklch,var(--bg)_75%,transparent)] backdrop-blur-[18px]">
341
+ <a href="/" class="mr-auto inline-flex items-center gap-2 no-underline text-fg font-semibold text-[15px] leading-none tracking-tight">
342
+ <span>${name}</span>
343
+ </a>
344
+ <nav class="flex gap-4 items-center">
345
+ \${navLink('/', 'Home')}
346
+ <theme-toggle></theme-toggle>
347
+ </nav>
348
+ </header>
349
+
350
+ <main class="block max-w-[760px] mx-auto px-4 sm:px-6 pt-[72px] pb-12 min-h-screen">
351
+ \${children}
352
+ </main>
353
+ \`;
354
+ }
355
+ `);
356
+
357
+ await writeFile(join(appDir, 'app', 'page.ts'), `import { html } from '@webjskit/core';
358
+ import { rubric, displayH1, accentLink } from './_utils/ui.ts';
359
+
360
+ export const metadata = {
361
+ title: '${name} — built with webjs',
362
+ };
363
+
364
+ export default function Home() {
365
+ return html\`
366
+ <section class="mb-18">
367
+ \${rubric('welcome')}
368
+ \${displayH1(html\`Hello from <span class="text-accent italic">${name}</span>.\`)}
369
+ <p class="text-lede leading-[1.5] text-fg-muted max-w-[56ch] m-0">
370
+ Edit <code class="font-mono text-[0.9em]">app/page.ts</code> to get started.
371
+ Run \${accentLink('#', 'npx webjs test')} to run tests and
372
+ \${accentLink('#', 'npx webjs check')} to validate conventions.
373
+ </p>
374
+ </section>
375
+
376
+ <section class="mt-18 pt-6 border-t border-border">
377
+ <h2 class="font-serif text-[1.6rem] tracking-[-0.02em] font-bold m-0 mb-2">Light DOM + Tailwind</h2>
378
+ <p class="text-fg-muted text-sm m-0 mb-4">
379
+ Components render into light DOM by default. Tailwind utility classes
380
+ apply directly. Set <code class="font-mono text-[0.9em]">static shadow = true</code>
381
+ on a component when you need scoped styles, &lt;slot&gt; projection,
382
+ or third-party-embed isolation.
383
+ </p>
384
+ </section>
385
+ \`;
386
+ }
387
+ `);
388
+
389
+ // --- AGENTS.md (copy from framework root) ---
390
+
391
+ const agentsSrc = resolve(__dirname, '..', '..', '..', 'AGENTS.md');
392
+ if (existsSync(agentsSrc)) {
393
+ await cp(agentsSrc, join(appDir, 'AGENTS.md'));
394
+ }
395
+
396
+ // --- Theme toggle component ---
397
+
398
+ await writeFile(join(appDir, 'components', 'theme-toggle.ts'), `import { WebComponent, html } from '@webjskit/core';
399
+
400
+ type Theme = 'system' | 'light' | 'dark';
401
+
402
+ /**
403
+ * <theme-toggle> — light DOM component styled with Tailwind utilities.
404
+ *
405
+ * Light DOM is the default: no static shadow = true, no static styles.
406
+ * Because this component has no custom CSS (only Tailwind classes,
407
+ * which are already unique by construction), the class-prefix rule
408
+ * doesn't apply here. If you ever add a <style> block, prefix every
409
+ * selector with 'theme-toggle' (e.g. .theme-toggle__btn or
410
+ * \`theme-toggle .btn\`).
411
+ */
412
+ export class ThemeToggle extends WebComponent {
413
+ declare state: { theme: Theme };
414
+
415
+ constructor() {
416
+ super();
417
+ this.state = { theme: 'system' };
418
+ }
419
+
420
+ connectedCallback() {
421
+ super.connectedCallback();
422
+ let saved: string | null = null;
423
+ try { saved = localStorage.getItem('webjs_theme'); } catch {}
424
+ this.setState({ theme: saved === 'light' || saved === 'dark' ? saved : 'system' });
425
+ }
426
+
427
+ cycle() {
428
+ const next: Theme = this.state.theme === 'system' ? 'light'
429
+ : this.state.theme === 'light' ? 'dark' : 'system';
430
+ this.setState({ theme: next });
431
+ try {
432
+ if (next === 'system') localStorage.removeItem('webjs_theme');
433
+ else localStorage.setItem('webjs_theme', next);
434
+ } catch {}
435
+ if (next === 'system') delete document.documentElement.dataset.theme;
436
+ else document.documentElement.dataset.theme = next;
437
+ }
438
+
439
+ render() {
440
+ return html\`
441
+ <button
442
+ class="inline-flex items-center px-3 py-1.5 rounded-full border border-border bg-bg-elev text-fg-muted font-mono text-[11px] leading-none tracking-wider uppercase duration-fast hover:text-fg hover:border-border-strong"
443
+ @click=\${() => this.cycle()}
444
+ >
445
+ \${this.state.theme === 'system' ? 'Auto'
446
+ : this.state.theme === 'light' ? 'Light' : 'Dark'}
447
+ </button>
448
+ \`;
449
+ }
450
+ }
451
+
452
+ ThemeToggle.register('theme-toggle');
453
+ `);
454
+ } // end if (!isApi)
455
+
456
+ // --- SaaS template extras: auth, dashboard, prisma ---
457
+ if (isSaas) {
458
+ const { writeSaasFiles } = await import('./saas-template.js');
459
+ await writeSaasFiles(appDir);
460
+ }
461
+
462
+ // --- AGENTS.md (always copy) ---
463
+ const agentsSrc2 = resolve(__dirname, '..', '..', '..', 'AGENTS.md');
464
+ if (!existsSync(join(appDir, 'AGENTS.md')) && existsSync(agentsSrc2)) {
465
+ await cp(agentsSrc2, join(appDir, 'AGENTS.md'));
466
+ }
467
+
468
+ // --- Git init + configure hooks directory ---
469
+ const { execSync } = await import('node:child_process');
470
+ try {
471
+ execSync('git init', { cwd: appDir, stdio: 'pipe' });
472
+ // Tell git to use .hooks/ as the hooks directory (tracked in the repo)
473
+ execSync('git config core.hooksPath .hooks', { cwd: appDir, stdio: 'pipe' });
474
+ } catch { /* git not available — skip */ }
475
+
476
+ // --- Print success ---
477
+
478
+ if (isApi) {
479
+ console.log(` ${name}/
480
+ app/api/health/route.ts
481
+ app/api/users/route.ts ← thin wrapper over server actions
482
+ modules/users/{actions,queries,types.ts}
483
+ CONVENTIONS.md, AGENTS.md, CLAUDE.md
484
+ `);
485
+ } else if (isSaas) {
486
+ console.log(` ${name}/
487
+ app/layout.ts, page.ts, login/, signup/
488
+ app/dashboard/{page,settings,middleware}.ts ← protected
489
+ app/api/auth/[...path]/route.ts ← auth API
490
+ modules/auth/{actions,queries,types.ts}
491
+ lib/{auth,prisma,password}.ts
492
+ prisma/schema.prisma ← User model
493
+ components/theme-toggle.ts
494
+ CONVENTIONS.md, AGENTS.md, CLAUDE.md
495
+ `);
496
+ } else {
497
+ console.log(` ${name}/
498
+ app/layout.ts, page.ts ← light DOM + Tailwind + @theme tokens
499
+ app/_utils/ui.ts ← JS helpers for repeated class bundles
500
+ public/tailwind-browser.js ← Tailwind runtime
501
+ components/theme-toggle.ts ← light DOM web component
502
+ modules/
503
+ CONVENTIONS.md, AGENTS.md, CLAUDE.md
504
+ `);
505
+ }
506
+ console.log(`Next steps:
507
+ cd ${name}
508
+ npm install${isSaas ? '\n npx prisma migrate dev --name init' : ''}
509
+ npx webjs dev
510
+
511
+ AI-driven development (enforced for all AI agents):
512
+ ✓ Tests auto-generated with every feature
513
+ ✓ Docs auto-updated with every change
514
+ ✓ Git merges/pushes to main require approval
515
+ ✓ Commits are automatic, small, and meaningful
516
+ ✓ No AI attribution in commit messages
517
+ ✓ Convention validation via \`npx webjs check\`
518
+ `);
519
+ }