@webjsdev/cli 0.10.44 → 0.10.45

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.
Files changed (45) hide show
  1. package/README.md +2 -3
  2. package/bin/webjs.js +18 -18
  3. package/lib/api-gallery.js +1 -1
  4. package/lib/create.js +51 -108
  5. package/package.json +1 -1
  6. package/templates/.agents/skills/webjs/references/client-router-and-streaming.md +10 -2
  7. package/templates/.agents/skills/webjs/references/components.md +19 -4
  8. package/templates/.agents/skills/webjs/references/muscle-memory-gotchas.md +5 -1
  9. package/templates/.agents/skills/webjs/references/runtime.md +1 -1
  10. package/templates/.agents/skills/webjs/references/service-worker.md +1 -1
  11. package/templates/gallery/app/api/auth/[...path]/route.ts +7 -0
  12. package/templates/gallery/app/examples/layout.ts +11 -0
  13. package/templates/gallery/app/features/auth/dashboard/layout.ts +20 -0
  14. package/templates/gallery/app/features/auth/dashboard/middleware.ts +14 -0
  15. package/templates/gallery/app/features/auth/dashboard/page.ts +18 -0
  16. package/templates/gallery/app/features/auth/dashboard/settings/page.ts +21 -0
  17. package/templates/gallery/app/features/auth/login/page.ts +40 -0
  18. package/templates/gallery/app/features/auth/page.ts +33 -0
  19. package/templates/gallery/app/features/auth/signup/page.ts +58 -0
  20. package/templates/gallery/app/features/frames/page.ts +10 -2
  21. package/templates/gallery/app/features/layout.ts +12 -0
  22. package/templates/gallery/app/features/server-actions/page.ts +4 -2
  23. package/templates/gallery/app/features/stream/page.ts +45 -0
  24. package/templates/gallery/app/features/streaming/page.ts +31 -0
  25. package/templates/gallery/app/features/suspense/page.ts +34 -0
  26. package/templates/gallery/app/features/view-transitions/page.ts +41 -0
  27. package/templates/gallery/app/features/view-transitions/second/page.ts +28 -0
  28. package/templates/gallery/modules/auth/actions/signup.server.ts +19 -0
  29. package/templates/gallery/modules/auth/auth.server.ts +53 -0
  30. package/templates/gallery/modules/auth/password.server.ts +20 -0
  31. package/templates/gallery/modules/auth/queries/current-user.server.ts +12 -0
  32. package/templates/gallery/modules/auth/types.ts +9 -0
  33. package/templates/gallery/modules/server-actions/actions/greet.server.ts +2 -2
  34. package/templates/gallery/modules/server-actions/actions/greet.test.ts +39 -36
  35. package/templates/gallery/modules/server-actions/components/greeter.ts +5 -8
  36. package/templates/gallery/modules/server-actions/middleware/require-auth.server.ts +14 -9
  37. package/templates/gallery/modules/stream/components/stream-demo.ts +76 -0
  38. package/templates/gallery/modules/streaming/actions/stream-tokens.server.ts +17 -0
  39. package/templates/gallery/modules/streaming/components/token-stream.ts +46 -0
  40. package/templates/gallery/modules/suspense/components/slow-fact.ts +19 -0
  41. package/templates/gallery/test/auth/auth.test.ts +81 -0
  42. package/templates/scripts/clear-api-gallery.mjs +55 -0
  43. package/templates/scripts/clear-gallery.mjs +23 -11
  44. package/lib/lean-copy.js +0 -43
  45. package/lib/saas-template.js +0 -568
@@ -1,15 +1,24 @@
1
1
  // Example node test for the documented test helpers (from @webjsdev/server).
2
2
  // Run it with node --test (or webjs test after moving it under test/). The
3
3
  // handle() harness drives the FULL request pipeline: createRequestHandler({
4
- // appDir }) builds it, and rawActionRequest() fires a 'use server' action
5
- // through it (CSRF + the rich serializer included), returning the raw Response.
4
+ // appDir }) builds it, and rawActionRequest() / invokeActionForTest() fire a
5
+ // 'use server' action through it (CSRF + the rich serializer included).
6
6
  // buildRouteTable(appDir) parses the file router; matchPage / matchApi resolve a
7
7
  // URL against it, params included. See the testing docs.
8
+ //
9
+ // greet is gated by the requireAuth middleware, which reads the REAL signed
10
+ // session off the request (the auth gallery card). So an unauthenticated call is
11
+ // genuinely denied (401), and the success path needs a real session cookie
12
+ // (obtained via a signup + loginAndGetCookies, skipped until the db is migrated).
8
13
  import { test } from 'node:test';
9
14
  import assert from 'node:assert/strict';
10
- import { createRequestHandler, buildRouteTable, matchPage, matchApi, rawActionRequest, invokeActionForTest } from '@webjsdev/server';
15
+ import { createRequestHandler, buildRouteTable, matchPage, matchApi, invokeActionForTest } from '@webjsdev/server';
16
+ import { testRequest, loginAndGetCookies } from '@webjsdev/server/testing';
11
17
 
12
18
  const appDir = process.cwd();
19
+ process.env.AUTH_SECRET ||= 'test-secret-at-least-32-characters-long!!';
20
+
21
+ const GREET = 'modules/server-actions/actions/greet.server.ts';
13
22
 
14
23
  test('buildRouteTable + matchPage resolve a dynamic route with its params', async () => {
15
24
  const table = await buildRouteTable(appDir);
@@ -24,47 +33,41 @@ test('matchApi resolves the route-handler endpoint', async () => {
24
33
  assert.ok(m, 'the route.ts endpoint matches');
25
34
  });
26
35
 
27
- test('rawActionRequest fires the greet action through the pipeline', async () => {
36
+ test('an unauthenticated greet is denied by requireAuth before greet runs', async () => {
28
37
  const app = await createRequestHandler({ appDir, dev: true });
29
38
  if (app.warmup) await app.warmup();
30
- const res = await rawActionRequest(
31
- app,
32
- 'modules/server-actions/actions/greet.server.ts',
33
- 'greet',
34
- [{ name: 'Ada' }],
35
- );
36
- assert.equal(res.status, 200);
39
+ // No session cookie -> requireAuth short-circuits with a 401 failure envelope
40
+ // (it reads only the cookie, so this is real without a migrated db).
41
+ const r = (await invokeActionForTest(
42
+ app, GREET, 'greet', [{ name: 'Bob' }], { throwOnError: false },
43
+ )) as { success: boolean; error?: string; status?: number };
44
+ assert.equal(r.success, false);
45
+ assert.equal(r.status, 401);
37
46
  });
38
47
 
39
- test('the middleware sets the caller on the context and greet reads it via actionContext()', async () => {
48
+ test('an authenticated greet reads the caller off the session via actionContext()', async (t) => {
40
49
  const app = await createRequestHandler({ appDir, dev: true });
41
50
  if (app.warmup) await app.warmup();
42
- // invokeActionForTest returns the deserialized result as unknown; cast to the
43
- // action's ActionResult shape to read it.
51
+
52
+ // Real signup through the auth card's page action, then a real login to capture
53
+ // the signed session cookie. Both hit the users table, so skip until the db is
54
+ // migrated (run db:generate + db:migrate) rather than fail misleadingly.
55
+ const email = `greet+${Date.now()}@example.com`;
56
+ const password = 'password123';
57
+ const signupRes = await testRequest(app.handle, '/features/auth/signup', {
58
+ method: 'POST',
59
+ headers: { 'content-type': 'application/x-www-form-urlencoded' },
60
+ body: new URLSearchParams({ name: 'Ada', email, password }).toString(),
61
+ });
62
+ if (signupRes.status !== 302) { t.skip('app deps/db not ready; run db:generate + db:migrate'); return; }
63
+ const { cookies } = await loginAndGetCookies(app.handle, { email, password });
64
+
65
+ // With the session cookie the middleware sets the caller, and greet reads it.
44
66
  const r = (await invokeActionForTest(
45
- app,
46
- 'modules/server-actions/actions/greet.server.ts',
47
- 'greet',
48
- [{ name: 'Bob' }],
49
- )) as { success: boolean; data?: { message: string }; error?: string; status?: number };
67
+ app, GREET, 'greet', [{ name: 'Bob' }], { extraCookies: cookies },
68
+ )) as { success: boolean; data?: { message: string } };
50
69
  assert.equal(r.success, true);
51
- // The message carries BOTH the input (Bob) and the middleware-set caller (Ada).
70
+ // The message carries BOTH the input (Bob) and the session caller (Ada).
52
71
  assert.match(r.data?.message ?? '', /BOB/);
53
72
  assert.match(r.data?.message ?? '', /Ada/);
54
73
  });
55
-
56
- test('the auth middleware short-circuits a signed-out request before greet runs', async () => {
57
- const app = await createRequestHandler({ appDir, dev: true });
58
- if (app.warmup) await app.warmup();
59
- // A middleware short-circuit rides as a normal failure envelope (200 with the
60
- // status inside), so read the result rather than expecting a thrown non-2xx.
61
- const r = (await invokeActionForTest(
62
- app,
63
- 'modules/server-actions/actions/greet.server.ts',
64
- 'greet',
65
- [{ name: 'Bob', signedOut: true }],
66
- { throwOnError: false },
67
- )) as { success: boolean; data?: { message: string }; error?: string; status?: number };
68
- assert.equal(r.success, false);
69
- assert.equal(r.status, 401);
70
- });
@@ -5,14 +5,14 @@ import { greet } from '../actions/greet.server.ts';
5
5
 
6
6
  export class Greeter extends WebComponent {
7
7
  private msg = signal('');
8
- // Drives the requireAuth middleware on the action: when true the request is
9
- // treated as signed-out, so the middleware 401s BEFORE greet() runs.
10
- private signedOut = signal(false);
11
8
 
12
9
  async run(e: SubmitEvent) {
13
10
  e.preventDefault();
14
11
  const name = String(new FormData(e.target as HTMLFormElement).get('name') ?? '');
15
- const r = await greet({ name, signedOut: this.signedOut.get() });
12
+ // The action's requireAuth middleware reads the real session off the request.
13
+ // Signed out, this comes back as a 401 failure envelope ("Sign in to
14
+ // continue."); sign in at /features/auth/login and the greeting succeeds.
15
+ const r = await greet({ name });
16
16
  // Narrow on r.success so TS knows `data` (success) vs `error` (failure). A
17
17
  // middleware short-circuit arrives here as a normal failure envelope.
18
18
  this.msg.set(r.success ? (r.data?.message ?? '') : (r.error ?? 'error'));
@@ -27,10 +27,7 @@ export class Greeter extends WebComponent {
27
27
  <button type="submit"
28
28
  class="shrink-0 px-4 py-2 rounded-xl bg-primary text-primary-foreground font-semibold text-sm border-0 cursor-pointer transition-all hover:bg-primary/90 active:scale-[0.97]">Greet</button>
29
29
  </form>
30
- <label class="flex items-center gap-2 text-sm text-muted-foreground cursor-pointer select-none">
31
- <input type="checkbox" @change=${(e: Event) => this.signedOut.set((e.target as HTMLInputElement).checked)} />
32
- Simulate a signed-out visitor (the middleware 401s before the action runs)
33
- </label>
30
+ <p class="m-0 text-sm text-muted-foreground">The action is gated by requireAuth. <a class="text-primary" href="/features/auth/login">Sign in</a> to greet; signed out returns a real 401.</p>
34
31
  ${this.msg.get() ? html`<p class="m-0 font-semibold text-foreground">${this.msg.get()}</p>` : ''}
35
32
  </div>
36
33
  `;
@@ -7,10 +7,16 @@
7
7
  // .server.ts with NO 'use server'): the action imports it server-side; it never
8
8
  // ships to the browser.
9
9
  import type { ActionResult } from '@webjsdev/server';
10
+ // This demo depends on the auth gallery card (modules/auth): getCurrentUser
11
+ // reads the real signed session. If you prune the auth card, prune this
12
+ // server-actions card too (or swap this for your own session read); gallery:clear
13
+ // removes both together.
14
+ import { getCurrentUser } from '#modules/auth/auth.server.ts';
10
15
 
11
16
  export interface AuthUser {
12
17
  id: string;
13
- name: string;
18
+ name?: string | null;
19
+ email?: string;
14
20
  }
15
21
 
16
22
  // The context object the framework passes each middleware. `context` is the
@@ -24,17 +30,16 @@ interface ActionMiddlewareCtx {
24
30
  }
25
31
 
26
32
  export async function requireAuth(ctx: ActionMiddlewareCtx, next: () => Promise<unknown>): Promise<unknown> {
27
- // A REAL guard reads the signed session or JWT off ctx.request, because auth
28
- // belongs to the request, not the payload. This gallery has no login backend on
29
- // the action path, so to keep BOTH branches exercisable the demo treats the
30
- // caller as signed in UNLESS the request asks to simulate a signed-out visitor
31
- // (the checkbox in the component sends { signedOut: true } in the action input).
32
- const [input] = ctx.args as [{ signedOut?: boolean } | undefined];
33
- const user: AuthUser | null = input?.signedOut ? null : { id: 'u_1', name: 'Ada' };
33
+ // A REAL guard reads the signed session off the request, because auth belongs
34
+ // to the request, not the payload. The RPC POST is same-origin, so the auth
35
+ // cookie rides along and getCurrentUser(ctx.request) reads it (this uses the
36
+ // auth gallery card's createAuth config; sign in at /features/auth/login).
37
+ const user = (await getCurrentUser(ctx.request)) as AuthUser | null;
34
38
 
35
39
  // Short-circuit: return an ActionResult WITHOUT calling next(), so the action
36
40
  // never runs. On the RPC boundary the short-circuit rides as the result with its
37
- // status inside the envelope, and a denied call is served no-store (never cached).
41
+ // status inside the envelope, and a denied call is served no-store (never
42
+ // cached). An anonymous caller is genuinely denied here.
38
43
  if (!user) return { success: false, error: 'Sign in to continue.', status: 401 } satisfies ActionResult<never>;
39
44
 
40
45
  ctx.context.user = user; // what actionContext().user reads inside the action
@@ -0,0 +1,76 @@
1
+ // <webjs-stream> is the element-level DOM-update grammar (#248): a self-applying
2
+ // element that clones its <template> and runs one native DOM method against a
3
+ // target (by id), then removes itself. `renderStream(htmlString)` from
4
+ // @webjsdev/core applies such a payload on the client. It is WebJs's take on
5
+ // Turbo Streams: surgical append / prepend / replace / update / remove, without
6
+ // redrawing a whole region.
7
+ //
8
+ // This demo drives it from the client with buttons. The list below is seeded
9
+ // ONCE in render(); every button mutates the LIVE DOM via renderStream() and the
10
+ // component never re-renders (the row counter is a plain field, deliberately not
11
+ // a signal, so reading it never re-runs render() and wipes the surgical updates).
12
+ // That is the whole point: out-of-band updates that a signal re-render or a frame
13
+ // region-swap would clobber.
14
+ import { WebComponent, html, renderStream } from '@webjsdev/core';
15
+
16
+ // Build a <webjs-stream> payload string. It is a plain string (NOT an html``
17
+ // template), so interpolating the row markup here is fine. `remove` needs no
18
+ // <template>; every other action wraps its content in one.
19
+ function streamPayload(action: string, target: string, inner = '') {
20
+ const body = action === 'remove' ? '' : `<template>${inner}</template>`;
21
+ return `<webjs-stream action="${action}" target="${target}">${body}</webjs-stream>`;
22
+ }
23
+
24
+ const rowCls = 'flex items-center gap-2 px-3 py-2 rounded-xl bg-card border border-border text-[15px] text-foreground';
25
+ const row = (id: string, label: string) => `<li id="${id}" class="${rowCls}">${label}</li>`;
26
+
27
+ export class StreamDemo extends WebComponent {
28
+ // A plain instance field, NOT a signal: incremented to mint unique row ids.
29
+ // It is never read inside render(), so appending a row does not re-render the
30
+ // component and blow away the streamed-in rows.
31
+ #n = 2;
32
+
33
+ append() {
34
+ this.#n++;
35
+ renderStream(streamPayload('append', 'stream-list', row(`row-${this.#n}`, `Row ${this.#n} (appended)`)));
36
+ }
37
+ prepend() {
38
+ this.#n++;
39
+ renderStream(streamPayload('prepend', 'stream-list', row(`row-${this.#n}`, `Row ${this.#n} (prepended)`)));
40
+ }
41
+ replaceFirst() {
42
+ // `replace` swaps the target element itself. The replacement keeps id row-1,
43
+ // so the button stays repeatable.
44
+ renderStream(streamPayload('replace', 'row-1', row('row-1', 'Row 1 (replaced)')));
45
+ }
46
+ removeSecond() {
47
+ // `remove` deletes the target and needs no <template>.
48
+ renderStream(streamPayload('remove', 'row-2'));
49
+ }
50
+ reset() {
51
+ // `update` replaces the target's children, restoring the seed list.
52
+ renderStream(streamPayload('update', 'stream-list', row('row-1', 'Row 1') + row('row-2', 'Row 2')));
53
+ }
54
+
55
+ render() {
56
+ const btn = 'px-3 py-1.5 rounded-lg bg-card border border-border text-sm font-medium text-foreground cursor-pointer transition-colors hover:border-border-strong';
57
+ return html`
58
+ <div class="grid gap-4 max-w-[460px]">
59
+ <div class="flex flex-wrap gap-2">
60
+ <button class=${btn} @click=${() => this.append()}>Append</button>
61
+ <button class=${btn} @click=${() => this.prepend()}>Prepend</button>
62
+ <button class=${btn} @click=${() => this.replaceFirst()}>Replace Row 1</button>
63
+ <button class=${btn} @click=${() => this.removeSecond()}>Remove Row 2</button>
64
+ <button class=${btn} @click=${() => this.reset()}>Reset</button>
65
+ </div>
66
+ <!-- The target list. renderStream() mutates it by id; this markup renders
67
+ once and is never re-rendered by the component. -->
68
+ <ul id="stream-list" class="grid gap-2 m-0 p-0 list-none">
69
+ <li id="row-1" class="flex items-center gap-2 px-3 py-2 rounded-xl bg-card border border-border text-[15px] text-foreground">Row 1</li>
70
+ <li id="row-2" class="flex items-center gap-2 px-3 py-2 rounded-xl bg-card border border-border text-[15px] text-foreground">Row 2</li>
71
+ </ul>
72
+ </div>
73
+ `;
74
+ }
75
+ }
76
+ StreamDemo.register('stream-demo');
@@ -0,0 +1,17 @@
1
+ 'use server';
2
+ // A STREAMING server action (#489). Detection is purely on the RETURN VALUE, no
3
+ // config export: an action that returns a ReadableStream / async iterable /
4
+ // async generator streams its chunks over the single RPC response instead of
5
+ // buffering. Each `yield` is rich-serialized and flushed as it is produced, so
6
+ // the call site sees tokens arrive live. Back-pressure is respected and the
7
+ // generator is cancelled if the client disconnects. A streamed result is never
8
+ // cached / ETagged / seeded. One function per file, like every action.
9
+ export async function* streamTokens(prompt: string): AsyncGenerator<string> {
10
+ const words = `Streaming ${prompt} one token at a time, straight from the server.`.split(' ');
11
+ for (const word of words) {
12
+ // A deliberate per-token delay so the streaming is visible; a real action
13
+ // would yield as its upstream (an LLM, a DB cursor, a log tail) produces.
14
+ await new Promise((r) => setTimeout(r, 140));
15
+ yield word + ' ';
16
+ }
17
+ }
@@ -0,0 +1,46 @@
1
+ // Consumes a streaming action at the call site with `for await`. The import of
2
+ // a `'use server'` action is rewritten to an RPC stub, so `await streamTokens()`
3
+ // resolves to an async iterable whose chunks arrive as the server yields them.
4
+ // We append each chunk to an instance signal, so the text builds up live as it
5
+ // streams (the built-in SignalWatcher re-renders on each `.set`). With JS off
6
+ // the button is inert and the empty output renders (streaming is inherently a
7
+ // JS behaviour), so nothing here breaks the no-JS first paint.
8
+ import { WebComponent, signal, html } from '@webjsdev/core';
9
+ import { streamTokens } from '../actions/stream-tokens.server.ts';
10
+
11
+ export class TokenStream extends WebComponent {
12
+ // Plain instance signals (not reactive props), so a class field is fine here.
13
+ private output = signal('');
14
+ private busy = signal(false);
15
+
16
+ private async run() {
17
+ this.output.set('');
18
+ this.busy.set(true);
19
+ try {
20
+ // `await streamTokens(...)` gives the async iterable; `for await` pulls
21
+ // each token as it arrives instead of waiting for the whole response.
22
+ for await (const chunk of await streamTokens('webjs')) {
23
+ this.output.set(this.output.get() + chunk);
24
+ }
25
+ } finally {
26
+ this.busy.set(false);
27
+ }
28
+ }
29
+
30
+ render() {
31
+ const busy = this.busy.get();
32
+ return html`
33
+ <div class="rounded-2xl border border-border bg-card p-5">
34
+ <button
35
+ @click=${() => this.run()}
36
+ ?disabled=${busy}
37
+ class="inline-flex items-center px-4 py-2 rounded-xl bg-primary text-primary-foreground font-semibold text-sm transition-all hover:bg-primary/90 active:scale-[0.97] disabled:opacity-60"
38
+ >
39
+ ${busy ? 'streaming…' : 'Stream tokens'}
40
+ </button>
41
+ <pre class="mt-4 whitespace-pre-wrap font-mono text-sm text-foreground min-h-[3rem]">${this.output.get()}</pre>
42
+ </div>
43
+ `;
44
+ }
45
+ }
46
+ TokenStream.register('token-stream');
@@ -0,0 +1,19 @@
1
+ // A bare async-render component whose server data is deliberately SLOW. On its
2
+ // own, async render() BLOCKS the SSR first byte (the resolved value lands in the
3
+ // first paint with no fallback, which is what the async-render demo shows). That
4
+ // is the right default for fast data. Here the data is slow, so the page wraps
5
+ // it in a <webjs-suspense> boundary that flushes its fallback on the first byte
6
+ // and STREAMS this content in when the await settles (progressively on soft
7
+ // navigation too). Same component either way; the boundary is what changes the
8
+ // SSR behaviour from block to stream.
9
+ import { WebComponent, html } from '@webjsdev/core';
10
+
11
+ export class SlowFact extends WebComponent {
12
+ async render() {
13
+ await new Promise((r) => setTimeout(r, 900));
14
+ return html`<p class="rounded-2xl border border-border bg-card p-5 text-foreground">
15
+ The answer, after a slow lookup, is <strong>42</strong>.
16
+ </p>`;
17
+ }
18
+ }
19
+ SlowFact.register('slow-fact');
@@ -0,0 +1,81 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { fileURLToPath } from 'node:url';
4
+ import { dirname, resolve } from 'node:path';
5
+
6
+ import { createRequestHandler } from '@webjsdev/server';
7
+ import { testRequest, loginAndGetCookies, withSessionCookie } from '@webjsdev/server/testing';
8
+
9
+ const appDir = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..');
10
+
11
+ // The auth pages + dashboard middleware query the users table via Drizzle. Until
12
+ // `db:generate` has authored the migration (then `db:migrate` applies it, or
13
+ // `dev` applies it via webjs.dev.before), a request hitting those modules 500s;
14
+ // we detect that at the RESPONSE level (a 5xx on the dashboard) and SKIP with a
15
+ // clear message rather than report a misleading failure. After the db is set up
16
+ // every assertion runs for real.
17
+ process.env.DATABASE_URL ||= 'file:./dev.db';
18
+ process.env.AUTH_SECRET ||= 'test-secret-at-least-32-characters-long!!';
19
+
20
+ function makeHandler() {
21
+ // createRequestHandler builds lazily, so it succeeds even before the DB is
22
+ // migrated; the missing table only surfaces when a request reaches a module
23
+ // that queries it. That is why readiness is probed per-response.
24
+ return createRequestHandler({ appDir, dev: true });
25
+ }
26
+
27
+ test('protected route redirects to login when unauthenticated', async (t) => {
28
+ const app = await makeHandler();
29
+ const res = await testRequest(app.handle, '/features/auth/dashboard');
30
+ if (res.status >= 500) {
31
+ t.skip('app deps not ready (run db:generate + db:migrate)');
32
+ return;
33
+ }
34
+ // The dashboard middleware calls auth(req); with no session cookie it 302s to
35
+ // login. This needs no DB row, only a cookie read, so it is always real once
36
+ // the modules import.
37
+ assert.equal(res.status, 302, 'unauthenticated dashboard is gated');
38
+ assert.equal(res.headers.get('location'), '/features/auth/login');
39
+ });
40
+
41
+ test('signup -> login -> dashboard renders for the authenticated user', async (t) => {
42
+ const app = await makeHandler();
43
+ // Probe readiness: a 5xx on the dashboard means deps/DB are not set up.
44
+ const probe = await testRequest(app.handle, '/features/auth/dashboard');
45
+ if (probe.status >= 500) { t.skip('app deps not ready; run db:generate + db:migrate'); return; }
46
+
47
+ const email = `harness+${Date.now()}@example.com`;
48
+ const password = 'password123';
49
+
50
+ // Real signup through the page server action (the no-JS form write-path).
51
+ let canSignup = true;
52
+ try {
53
+ const signupRes = await testRequest(app.handle, '/features/auth/signup', {
54
+ method: 'POST',
55
+ headers: { 'content-type': 'application/x-www-form-urlencoded' },
56
+ body: new URLSearchParams({ name: 'Harness', email, password }).toString(),
57
+ });
58
+ // Success auto-logs-in and 302s to the dashboard (carrying the session
59
+ // cookie); a 422 means validation failed. Either way the action ran.
60
+ assert.ok([302, 422].includes(signupRes.status), 'signup action ran');
61
+ if (signupRes.status === 302) assert.equal(signupRes.headers.get('location'), '/features/auth/dashboard', 'signup lands on the dashboard');
62
+ if (signupRes.status !== 302) canSignup = false;
63
+ } catch {
64
+ // No migrated DB table -> the action throws. Skip the DB-backed assertions.
65
+ canSignup = false;
66
+ }
67
+ if (!canSignup) { t.skip('no migrated DB; run db:migrate to enable the full flow'); return; }
68
+
69
+ // Real login captures the genuine signed session cookie.
70
+ const { cookies } = await loginAndGetCookies(app.handle, { email, password });
71
+
72
+ // With the session cookie the protected route now renders (200).
73
+ const dash = await testRequest(app.handle, '/features/auth/dashboard', withSessionCookie({}, cookies));
74
+ assert.equal(dash.status, 200, 'the session cookie unlocks the dashboard');
75
+ const body = await dash.text();
76
+ assert.match(body, /Dashboard/, 'the dashboard content rendered');
77
+ // The greeting interpolates the real user, so the name renders and the literal
78
+ // template source never leaks (a counterfactual for the escaping bug).
79
+ assert.match(body, /Harness/, 'the dashboard greets the signed-in user by name');
80
+ assert.ok(!body.includes('${user'), 'the greeting interpolation is not a literal string');
81
+ });
@@ -0,0 +1,55 @@
1
+ #!/usr/bin/env node
2
+ // Clear the api scaffold's backend-features showcase to a clean, buildable base.
3
+ //
4
+ // The api template ships a browsable showcase (JSON/HTTP endpoints under
5
+ // app/api/features/, their modules, an env.ts example, and a starter test) so an
6
+ // agent can learn the backend idioms from real, running code. It is the api
7
+ // counterpart of the UI gallery's `gallery:clear`. Learn from the demos relevant
8
+ // to your task first (the skill at .agents/skills/webjs/ teaches the same
9
+ // patterns and SURVIVES this reset), then run this once to shed the showcase, then
10
+ // grow the api in place.
11
+ //
12
+ // It removes the showcase routes + modules + the env.ts example + the widgets
13
+ // test, and resets the root app/route.ts to list only the kept baseline endpoints
14
+ // (/api/health, /api/users). It KEEPS the agent skill, middleware.ts (CORS), the
15
+ // health + users endpoints, modules/users, the database wiring, and the users
16
+ // example test. It is a one-time reset: if the showcase is already gone (no
17
+ // app/api/features/) it does nothing, so a rerun never clobbers an app you built.
18
+ import { rmSync, existsSync, readFileSync, writeFileSync } from 'node:fs';
19
+ import { join } from 'node:path';
20
+
21
+ const root = process.cwd();
22
+ const rm = (p) => { if (existsSync(join(root, p))) { rmSync(join(root, p), { recursive: true, force: true }); return true; } return false; };
23
+
24
+ // Guard: the showcase is identified by app/api/features/. If it is absent, the
25
+ // showcase was already cleared (or this is not an api scaffold), so exit before
26
+ // any destructive write. This keeps a rerun safe and never clobbers a built app.
27
+ if (!existsSync(join(root, 'app/api/features'))) {
28
+ console.log('No backend-features showcase found (app/api/features/ is absent); nothing to clear.');
29
+ process.exit(0);
30
+ }
31
+
32
+ // 1) The showcase route tree + its module + the env.ts example + the widgets test.
33
+ const showcasePaths = [
34
+ 'app/api/features',
35
+ 'modules/widgets',
36
+ 'env.ts',
37
+ 'test/unit/widgets.test.ts',
38
+ ];
39
+
40
+ let removed = 0;
41
+ for (const p of showcasePaths) if (rm(p)) removed++;
42
+
43
+ // 2) Reset the root app/route.ts to list only the kept baseline endpoints. The
44
+ // generated index also lists a `features:` block linking the showcase; strip it
45
+ // (and its comment) so the index does not point at routes that no longer exist.
46
+ // The regex preserves everything else (the app name, the health + users links).
47
+ const routePath = join(root, 'app/route.ts');
48
+ if (existsSync(routePath)) {
49
+ let s = readFileSync(routePath, 'utf8');
50
+ s = s.replace(/\n[ \t]*\/\/ The backend-features showcase[^\n]*\n[ \t]*features: \{[\s\S]*?\n[ \t]*\},\n/, '\n');
51
+ writeFileSync(routePath, s);
52
+ }
53
+
54
+ console.log(`Backend-features showcase cleared (${removed} paths removed). The agent skill, your database wiring, and the health + users endpoints are kept.`);
55
+ console.log('Next: build your api in app/api/ and modules/, keeping server-only code behind .server.ts.');
@@ -10,11 +10,12 @@
10
10
  // once to shed the gallery, then grow the app in place.
11
11
  //
12
12
  // It removes the gallery routes + modules + demo metadata routes, resets
13
- // app/page.ts to a minimal home, and drops the demo `todos` table from the
14
- // schema. It KEEPS the agent skill (.agents/skills/webjs/), the layout, the
15
- // database wiring, the theme toggle, and (for the saas template) the auth
16
- // modules. It is a one-time reset: if the gallery is already gone (no
17
- // app/features/) it does nothing, so a rerun never clobbers an app you built.
13
+ // app/page.ts to a minimal home, and drops the demo `todos` table plus the auth
14
+ // card's `passwordHash` column from the schema. It KEEPS the agent skill
15
+ // (.agents/skills/webjs/), the layout, the database wiring, the theme toggle, and
16
+ // the example `users` table. It is a one-time reset: if the gallery is already
17
+ // gone (no app/features/) it does nothing, so a rerun never clobbers an app you
18
+ // built.
18
19
  import { rmSync, existsSync, readFileSync, writeFileSync } from 'node:fs';
19
20
  import { join } from 'node:path';
20
21
 
@@ -30,18 +31,24 @@ if (!existsSync(join(root, 'app/features'))) {
30
31
  process.exit(0);
31
32
  }
32
33
 
33
- // 1) Gallery route trees + example metadata routes.
34
+ // 1) Gallery route trees + example metadata routes. `app/api/auth` is the auth
35
+ // card's createAuth handler (it lives at the app root, not under app/features/,
36
+ // because createAuth hardcodes /api/auth/*), and `test/auth` is the auth card's
37
+ // request-pipeline test, so both are removed here alongside the card.
34
38
  const galleryPaths = [
35
- 'app/features', 'app/examples', 'app/sitemaps',
39
+ 'app/features', 'app/examples', 'app/sitemaps', 'app/api/auth', 'test/auth',
36
40
  'app/icon.ts', 'app/apple-icon.ts', 'app/manifest.ts', 'app/opengraph-image.ts',
37
41
  'app/twitter-image.ts', 'app/robots.ts', 'app/sitemap.ts',
38
42
  'app/global-error.ts', 'app/global-not-found.ts',
39
43
  ];
40
- // 2) The gallery's feature modules (by name, so saas auth modules survive).
44
+ // 2) The gallery's feature modules (by name). `auth` is the auth card's server
45
+ // modules (createAuth config, password hashing, signup action, current-user
46
+ // query), pruned with the rest of the card.
41
47
  const galleryModules = [
42
- 'async-render', 'broadcast', 'caching', 'client-router', 'components',
48
+ 'async-render', 'auth', 'broadcast', 'caching', 'client-router', 'components',
43
49
  'directives', 'file-storage', 'frames', 'optimistic-ui', 'rate-limit',
44
- 'route-handler', 'server-actions', 'sessions', 'todo', 'websockets',
50
+ 'route-handler', 'server-actions', 'sessions', 'stream', 'streaming', 'suspense',
51
+ 'todo', 'websockets',
45
52
  ].map((m) => `modules/${m}`);
46
53
 
47
54
  let removed = 0;
@@ -50,12 +57,17 @@ for (const p of [...galleryPaths, ...galleryModules]) if (rm(p)) removed++;
50
57
  // 3) Reset app/page.ts to a minimal home (no gallery grid, no dead links).
51
58
  writeFileSync(join(root, 'app/page.ts'), MINIMAL_PAGE());
52
59
 
53
- // 4) Drop the demo `todos` table from the schema (keep everything else).
60
+ // 4) Drop the demo `todos` table and the auth card's `passwordHash` column from
61
+ // the schema (keep the example `users` table and everything else), reverting the
62
+ // schema to the minimal base.
54
63
  const schemaPath = join(root, 'db/schema.server.ts');
55
64
  if (existsSync(schemaPath)) {
56
65
  let s = readFileSync(schemaPath, 'utf8');
57
66
  s = s.replace(/\n(?:\/\/[^\n]*\n)*export const todos = table\('todos',[\s\S]*?\n\}\);\n/, '\n');
58
67
  s = s.replace(/defineRelations\(\{ users, todos \}/, 'defineRelations({ users }');
68
+ // Strip the auth `passwordHash` column (and its leading comment lines) from the
69
+ // users table; the rest of the table is the minimal example base.
70
+ s = s.replace(/\n(?:[ \t]*\/\/[^\n]*\n)*[ \t]*passwordHash: text\(\),\n/, '\n');
59
71
  writeFileSync(schemaPath, s);
60
72
  }
61
73
 
package/lib/lean-copy.js DELETED
@@ -1,43 +0,0 @@
1
- /**
2
- * The scaffold's lean-copy of a ui component (#983).
3
- *
4
- * `webjs create` copies a few `@webjsdev/ui` registry components into a
5
- * generated app. To match what `webjs ui add` writes, a Tier-1 helper's worked
6
- * `@example` is stripped (the example is served on demand by `webjs ui view` /
7
- * the MCP `ui` tool), while a Tier-2 element file is kept whole. Both scaffold
8
- * copiers (`create.js` and `saas-template.js`) go through THIS one helper so
9
- * they cannot drift.
10
- *
11
- * The strip primitives live in `@webjsdev/ui/registry/extract`; if that subpath
12
- * cannot be resolved, this degrades to a no-op (keep the example) so the strip
13
- * is never a reason `webjs create` fails.
14
- *
15
- * @module lean-copy
16
- */
17
-
18
- let _mod = null;
19
-
20
- async function loadPrimitives() {
21
- if (_mod) return _mod;
22
- try {
23
- const m = await import('@webjsdev/ui/registry/extract');
24
- _mod = { stripExample: m.stripExample, isCustomElementSource: m.isCustomElementSource };
25
- } catch {
26
- _mod = { stripExample: (s) => s, isCustomElementSource: () => true };
27
- }
28
- return _mod;
29
- }
30
-
31
- /**
32
- * Return the component source as `webjs ui add` would write it: a Tier-1 helper
33
- * has its worked `@example` stripped and a pointer left; a Tier-2 element is
34
- * returned unchanged.
35
- *
36
- * @param {string} source the component source (imports already rewritten)
37
- * @param {string} name the component name (for the pointer)
38
- * @returns {Promise<string>}
39
- */
40
- export async function leanComponentSource(source, name) {
41
- const { stripExample, isCustomElementSource } = await loadPrimitives();
42
- return isCustomElementSource(source) ? source : stripExample(source, name);
43
- }