create-nwire 0.7.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.
Files changed (40) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +50 -0
  3. package/bin.mjs +3 -0
  4. package/dist/__tests__/scaffold.test.d.ts +9 -0
  5. package/dist/__tests__/scaffold.test.d.ts.map +1 -0
  6. package/dist/__tests__/scaffold.test.js +83 -0
  7. package/dist/__tests__/scaffold.test.js.map +1 -0
  8. package/dist/index.d.ts +30 -0
  9. package/dist/index.d.ts.map +1 -0
  10. package/dist/index.js +168 -0
  11. package/dist/index.js.map +1 -0
  12. package/package.json +38 -0
  13. package/templates/L1/README.md +34 -0
  14. package/templates/L1/_gitignore +5 -0
  15. package/templates/L1/app/api.ts +18 -0
  16. package/templates/L1/app/main.ts +17 -0
  17. package/templates/L1/package.json +21 -0
  18. package/templates/L1/tsconfig.json +15 -0
  19. package/templates/L2/README.md +50 -0
  20. package/templates/L2/_gitignore +5 -0
  21. package/templates/L2/app/api.ts +125 -0
  22. package/templates/L2/app/errors.ts +48 -0
  23. package/templates/L2/app/main.ts +35 -0
  24. package/templates/L2/app/middleware.ts +32 -0
  25. package/templates/L2/app/model.ts +60 -0
  26. package/templates/L2/app/store.ts +122 -0
  27. package/templates/L2/package.json +24 -0
  28. package/templates/L2/tsconfig.json +15 -0
  29. package/templates/L4/README.md +52 -0
  30. package/templates/L4/_gitignore +5 -0
  31. package/templates/L4/app/actions.ts +81 -0
  32. package/templates/L4/app/api.ts +118 -0
  33. package/templates/L4/app/app.ts +28 -0
  34. package/templates/L4/app/auto-moderate.workflow.ts +78 -0
  35. package/templates/L4/app/events.ts +53 -0
  36. package/templates/L4/app/main.ts +39 -0
  37. package/templates/L4/app/moderation.module.ts +28 -0
  38. package/templates/L4/app/queue.projection.ts +162 -0
  39. package/templates/L4/package.json +25 -0
  40. package/templates/L4/tsconfig.json +15 -0
@@ -0,0 +1,118 @@
1
+ /**
2
+ * The HTTP interface — six routes covering submit + decide + dashboard.
3
+ *
4
+ * Resolvers call into actions and queries via the forge runtime. The
5
+ * runtime is the implicit bus — `dispatch(action, input)` runs an
6
+ * action through the full pipeline (validation, retry, DLQ, event
7
+ * recording, workflow notification, projection folding). `query(name,
8
+ * input)` reads the projection's materialized state.
9
+ *
10
+ * The HTTP shape is intentionally thin: translate request → action
11
+ * input, dispatch, return a tagged response. All the orchestration
12
+ * (auto-moderate workflow, projection updates) happens downstream of
13
+ * the event the action emits — never inside the resolver.
14
+ *
15
+ * Once `createApp` is also extracted to `@nwire/app` (v1.1), these
16
+ * resolvers will use `ctx.execute(action, input)` + `ctx.query(...)`
17
+ * directly. For v1.0 we route through the runtime explicitly.
18
+ */
19
+
20
+ import { httpInterface, get, post } from "@nwire/http";
21
+ import { z } from "zod";
22
+ import { app } from "./app";
23
+ import { submitPost, approvePost, rejectPost } from "./actions";
24
+
25
+ /**
26
+ * Tiny adapter — the resolver layer needs the runtime to dispatch
27
+ * actions and read projections. We hand it the unwrapped runtime
28
+ * instance from the forge app. v1.1's createApp extraction will
29
+ * expose this through `ctx.execute()` / `ctx.query()` directly.
30
+ */
31
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
32
+ const runtime = (app as any).runtime;
33
+
34
+ export const api = httpInterface({ prefix: "/api" })
35
+ // POST /api/posts — author submits a draft for moderation
36
+ .wire(
37
+ post("/posts", {
38
+ body: z.object({
39
+ authorId: z.string(),
40
+ body: z.string().min(1).max(2000),
41
+ }),
42
+ }),
43
+ async ({ input }) => {
44
+ await runtime.dispatch(submitPost, input);
45
+ /**
46
+ * Return 202 — accepted, not done. The submitPost action
47
+ * recorded PostWasSubmitted; the auto-moderate workflow + the
48
+ * dashboard projection will run downstream of that. By the time
49
+ * the client polls GET /queue the post may already be auto-decided.
50
+ */
51
+ return { $status: 202, body: { accepted: true } };
52
+ },
53
+ )
54
+
55
+ // POST /api/posts/:postId/approve — human moderator approves
56
+ .wire(
57
+ post("/posts/:postId/approve", {
58
+ params: z.object({ postId: z.string() }),
59
+ body: z.object({ moderatorId: z.string() }),
60
+ }),
61
+ async ({ input }) => {
62
+ const { postId, moderatorId } = input as { postId: string; moderatorId: string };
63
+ await runtime.dispatch(approvePost, {
64
+ postId,
65
+ approvedBy: moderatorId,
66
+ });
67
+ return { $status: 202, body: { accepted: true } };
68
+ },
69
+ )
70
+
71
+ // POST /api/posts/:postId/reject — human moderator rejects with reason
72
+ .wire(
73
+ post("/posts/:postId/reject", {
74
+ params: z.object({ postId: z.string() }),
75
+ body: z.object({
76
+ moderatorId: z.string(),
77
+ reason: z.string().min(1).max(500),
78
+ }),
79
+ }),
80
+ async ({ input }) => {
81
+ const { postId, moderatorId, reason } = input as {
82
+ postId: string;
83
+ moderatorId: string;
84
+ reason: string;
85
+ };
86
+ await runtime.dispatch(rejectPost, {
87
+ postId,
88
+ rejectedBy: moderatorId,
89
+ reason,
90
+ });
91
+ return { $status: 202, body: { accepted: true } };
92
+ },
93
+ )
94
+
95
+ // GET /api/queue — dashboard view
96
+ .wire(
97
+ get("/queue", {
98
+ query: z.object({
99
+ limit: z.coerce.number().int().min(1).max(100).default(20),
100
+ }),
101
+ }),
102
+ async ({ input }) => ({
103
+ items: await runtime.query("moderation.list-pending", input),
104
+ totals: await runtime.query("moderation.queue-totals", {}),
105
+ }),
106
+ )
107
+
108
+ // GET /api/posts/:postId — single post lookup
109
+ .wire(
110
+ get("/posts/:postId", {
111
+ params: z.object({ postId: z.string() }),
112
+ }),
113
+ async ({ input }) => {
114
+ const post = await runtime.query("moderation.get-post", input);
115
+ if (!post) return { $status: 404, body: { error: { code: "POST_NOT_FOUND" } } };
116
+ return post;
117
+ },
118
+ );
@@ -0,0 +1,28 @@
1
+ /**
2
+ * `createApp` composes the moderation module into a runnable app.
3
+ *
4
+ * Why createApp at Level 2 (when todo-app skipped it):
5
+ *
6
+ * - We have a workflow that subscribes to events on the bus. The
7
+ * workflow needs the runtime to register its subscription and
8
+ * fire it on PostWasSubmitted. That's what `createApp` orchestrates.
9
+ *
10
+ * - We have a projection that folds events into state. Same story —
11
+ * the runtime registers the projection's reducers and calls them
12
+ * on every matching event.
13
+ *
14
+ * - We have actions that emit events. The runtime persists those
15
+ * events through the implicit bus and routes them to all subscribers.
16
+ *
17
+ * One module, no plugins yet — but the moment we add a Postgres-backed
18
+ * projection store, a Redis cache, OTel tracing, an audit log plugin,
19
+ * they all attach here via `.use(plugin)`. The same `createApp` API
20
+ * scales from one module to dozens.
21
+ */
22
+
23
+ import { createApp } from "@nwire/forge";
24
+ import { moderationModule } from "./moderation.module";
25
+
26
+ export const app = createApp({
27
+ modules: [moderationModule],
28
+ });
@@ -0,0 +1,78 @@
1
+ /**
2
+ * `autoModerate` — workflow that runs cheap auto-moderation on every
3
+ * submitted post and decides whether to bypass the human queue.
4
+ *
5
+ * Pattern: workflows are subscribers — they react to events with the
6
+ * same `on(Event, handler)` shape projections use. The difference is
7
+ * what they DO with the event:
8
+ *
9
+ * - Workflows produce SIDE EFFECTS — dispatch more actions, send
10
+ * external messages, schedule timers, fire integrations.
11
+ * - Projections produce READ STATE — fold events into a denormalized
12
+ * model that queries read from.
13
+ *
14
+ * Same subscription primitive; different intent. The symmetry is real —
15
+ * if you have one, you have the shape to add the other.
16
+ *
17
+ * ## Why a workflow here vs. doing it inline in the resolver
18
+ *
19
+ * The HTTP resolver for "submit post" should return quickly — the user
20
+ * is waiting on a response. Running auto-moderation (which might call
21
+ * an LLM, scan a profanity list, lookup author reputation) takes time
22
+ * we can't afford in the request path.
23
+ *
24
+ * The workflow decouples the two: the action returns immediately after
25
+ * recording PostWasSubmitted; the workflow picks up the event async and
26
+ * does the heavy lifting. From the user's perspective: "Submitted!"
27
+ * From the system's perspective: the moderation flow runs to completion
28
+ * with retries, DLQ, and replay if the worker process restarts.
29
+ */
30
+
31
+ import { defineWorkflow } from "@nwire/forge";
32
+ import { PostWasSubmitted } from "./events";
33
+ import { approvePost, rejectPost } from "./actions";
34
+
35
+ /**
36
+ * Trivial moderation check — in a real app this would call an LLM,
37
+ * check against a banned-words list, look up author reputation, etc.
38
+ * Returns "approve" for clean content, "reject" with a reason for
39
+ * obvious violations, "human" when the content is ambiguous and needs
40
+ * a moderator's eye.
41
+ */
42
+ function autoCheck(body: string): "approve" | { reject: string } | "human" {
43
+ if (body.toLowerCase().includes("spam-marker")) return { reject: "Auto-detected spam" };
44
+ if (body.length < 5) return "human";
45
+ return "approve";
46
+ }
47
+
48
+ export const autoModerate = defineWorkflow("auto-moderate", ({ on, send }) => {
49
+ on(PostWasSubmitted, async (event) => {
50
+ /**
51
+ * Three possible outcomes from the auto-check. Each maps to either
52
+ * dispatching the matching action (approve/reject) or doing nothing
53
+ * and letting a human moderator handle it via the HTTP endpoints.
54
+ *
55
+ * `send(action, input)` is the workflow's way to dispatch — it
56
+ * runs the action through the same pipeline as an HTTP-triggered
57
+ * dispatch, including retry/DLQ/idempotency.
58
+ */
59
+ const decision = autoCheck(event.body);
60
+
61
+ if (decision === "approve") {
62
+ await send(approvePost, { postId: event.postId, approvedBy: "auto" });
63
+ return;
64
+ }
65
+
66
+ if (typeof decision === "object") {
67
+ await send(rejectPost, {
68
+ postId: event.postId,
69
+ rejectedBy: "auto",
70
+ reason: decision.reject,
71
+ });
72
+ return;
73
+ }
74
+
75
+ // "human" — do nothing; the post stays in the pending queue,
76
+ // visible to moderators via GET /queue.
77
+ });
78
+ });
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Domain events for the moderation queue.
3
+ *
4
+ * Each event is past-tense — it represents a fact that happened, not a
5
+ * command to do something. `WasSubmitted`, `WasApproved`, `WasRejected`.
6
+ * The tense is the whole grammar: events name the past, actions name the
7
+ * present-imperative.
8
+ *
9
+ * `outcome` and `audience` are Studio-aware metadata — the scanner picks
10
+ * them up so the dashboard can render the event with the right semantics
11
+ * ("milestone", "success", "failure") and route to the right subscribers.
12
+ */
13
+
14
+ import { z } from "zod";
15
+ import { defineEvent } from "@nwire/messages";
16
+
17
+ export const PostWasSubmitted = defineEvent({
18
+ name: "moderation.post-was-submitted",
19
+ description: "An author drafts a post and submits it for moderation.",
20
+ outcome: "milestone",
21
+ audience: ["product", "ops"],
22
+ schema: z.object({
23
+ postId: z.string(),
24
+ authorId: z.string(),
25
+ body: z.string(),
26
+ submittedAt: z.string().datetime(),
27
+ }),
28
+ });
29
+
30
+ export const PostWasApproved = defineEvent({
31
+ name: "moderation.post-was-approved",
32
+ description: "A moderator (or the auto-moderation workflow) approves a post.",
33
+ outcome: "success",
34
+ audience: ["product", "ops"],
35
+ schema: z.object({
36
+ postId: z.string(),
37
+ approvedBy: z.string().describe("Moderator id, or 'auto' for workflow-driven approval"),
38
+ approvedAt: z.string().datetime(),
39
+ }),
40
+ });
41
+
42
+ export const PostWasRejected = defineEvent({
43
+ name: "moderation.post-was-rejected",
44
+ description: "A moderator (or the auto-moderation workflow) rejects a post.",
45
+ outcome: "failure",
46
+ audience: ["product", "ops"],
47
+ schema: z.object({
48
+ postId: z.string(),
49
+ rejectedBy: z.string(),
50
+ reason: z.string(),
51
+ rejectedAt: z.string().datetime(),
52
+ }),
53
+ });
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Entry — boot the createApp and run the http interface under endpoint().
3
+ *
4
+ * The order matters:
5
+ *
6
+ * 1. `app` is a configured-but-unbooted forge app — modules registered,
7
+ * workflow subscribers wired, projection reducers attached, but
8
+ * nothing's actually running yet.
9
+ * 2. `app.start()` boots the runtime — workflows subscribe to the bus,
10
+ * projection state initializes, framework events fire.
11
+ * 3. `endpoint().serve(api).run()` starts the HTTP listener + K8s probes.
12
+ *
13
+ * Once running:
14
+ * - HTTP `POST /api/posts` → submitPost action → PostWasSubmitted event
15
+ * - autoModerate workflow picks up the event, runs auto-check, dispatches
16
+ * approve/reject if obvious
17
+ * - queueDashboard projection picks up ALL three events, updates state
18
+ * - HTTP `GET /api/queue` reads from the projection
19
+ *
20
+ * Run: pnpm dev
21
+ * Try: curl -X POST http://localhost:3000/api/posts \
22
+ * -H "content-type: application/json" \
23
+ * -d '{"authorId":"alice","body":"Hello, world!"}'
24
+ * curl http://localhost:3000/api/queue
25
+ */
26
+
27
+ import { endpoint } from "@nwire/endpoint";
28
+ import { app } from "./app";
29
+ import { api } from "./api";
30
+
31
+ await app.start();
32
+
33
+ // `.inspect(app)` mounts the `/_nwire/*` introspection surface that
34
+ // Studio's Live + Trace pages consume — live telemetry SSE stream,
35
+ // actor browser, projection viewer, DLQ list, manual dispatch.
36
+ // Off-by-default; opt in to demonstrate the dev-mode introspection.
37
+ api.inspect(app);
38
+
39
+ await endpoint("{{PROJECT_NAME}}", { port: 3000 }).serve(api).run();
@@ -0,0 +1,28 @@
1
+ /**
2
+ * The moderation module — collects all the domain pieces for this
3
+ * bounded context. `defineModule` is purely organizational: it groups
4
+ * actions / events / workflows / projections / queries that belong
5
+ * to one part of the business, marks which ones are public (callable
6
+ * from other modules / apps), and gives the runtime a single value
7
+ * to register.
8
+ *
9
+ * `.public()` controls cross-module visibility — see
10
+ * `architecture-primitives.html` for the full rules. Here every action
11
+ * and query is public because we have one module; in a multi-module
12
+ * app you'd be more selective.
13
+ */
14
+
15
+ import { defineModule } from "@nwire/forge";
16
+ import { PostWasSubmitted, PostWasApproved, PostWasRejected } from "./events";
17
+ import { submitPost, approvePost, rejectPost } from "./actions";
18
+ import { autoModerate } from "./auto-moderate.workflow";
19
+ import { queueDashboard, listPending, queueTotals, getPost } from "./queue.projection";
20
+
21
+ export const moderationModule = defineModule("moderation", {
22
+ description: "Post moderation queue — submit, auto-check, decide, dashboard.",
23
+ events: [PostWasSubmitted, PostWasApproved, PostWasRejected],
24
+ actions: [submitPost.public(), approvePost.public(), rejectPost.public()],
25
+ workflows: [autoModerate],
26
+ projections: [queueDashboard],
27
+ queries: [listPending.public(), queueTotals.public(), getPost.public()],
28
+ });
@@ -0,0 +1,162 @@
1
+ /**
2
+ * `queueDashboard` projection — the read model for the moderation queue.
3
+ *
4
+ * NOTICE THE SYMMETRY:
5
+ *
6
+ * - The autoModerate WORKFLOW (./auto-moderate.workflow.ts) subscribes
7
+ * to events via `on(PostWasSubmitted, (event) => ...)`. Its job is
8
+ * to produce SIDE EFFECTS — dispatch more actions.
9
+ *
10
+ * - This PROJECTION subscribes to events via the same `on` map shape.
11
+ * Its job is to fold events into a denormalized state that queries
12
+ * read from. No side effects; pure state derivation.
13
+ *
14
+ * Same subscription primitive. Same `on(Event)` ergonomics. Different
15
+ * intent. If you understand workflows, you understand projections —
16
+ * just write to state instead of dispatching.
17
+ *
18
+ * ## Why a projection vs. computing on every read
19
+ *
20
+ * The HTTP `GET /queue?status=pending` could query the event log + fold
21
+ * matching events on every request. That works for low traffic. For a
22
+ * busy moderation dashboard with thousands of moderators each polling
23
+ * every 5 seconds, that's wasted CPU.
24
+ *
25
+ * The projection folds incrementally: every time an event fires, it
26
+ * updates the state. Queries read the materialized state directly.
27
+ * Trade-off: more memory, less CPU; eventually-consistent (the
28
+ * projection lags the event by milliseconds at worst). For dashboards
29
+ * that's the right trade.
30
+ *
31
+ * ## When this lives in a database
32
+ *
33
+ * The in-memory state here is for example clarity. In production the
34
+ * projection's `on` reducers would write to Postgres / Mongo / Redis,
35
+ * and the query would read from there. The PRIMITIVE is the same —
36
+ * `on(Event) → mutate state`. Where state lives is a deployment choice.
37
+ */
38
+
39
+ import { defineProjection, defineQuery } from "@nwire/forge";
40
+ import { z } from "zod";
41
+ import { PostWasSubmitted, PostWasApproved, PostWasRejected } from "./events";
42
+
43
+ // ─── The projection state shape ────────────────────────────────
44
+
45
+ interface QueueItem {
46
+ postId: string;
47
+ authorId: string;
48
+ body: string;
49
+ status: "pending" | "approved" | "rejected";
50
+ submittedAt: string;
51
+ decidedAt?: string;
52
+ decidedBy?: string;
53
+ reason?: string;
54
+ }
55
+
56
+ interface QueueState {
57
+ byId: Record<string, QueueItem>;
58
+ pendingIds: string[]; // ordered, oldest first
59
+ totals: { pending: number; approved: number; rejected: number };
60
+ }
61
+
62
+ // ─── The projection — note the `on` map matches the workflow's ───
63
+
64
+ export const queueDashboard = defineProjection<QueueState>("queue-dashboard", {
65
+ description: "The moderation dashboard read model — pending queue + decision totals.",
66
+ listens: [PostWasSubmitted, PostWasApproved, PostWasRejected],
67
+ initial: () => ({
68
+ byId: {},
69
+ pendingIds: [],
70
+ totals: { pending: 0, approved: 0, rejected: 0 },
71
+ }),
72
+ on: {
73
+ /**
74
+ * Same shape as a workflow's `on(PostWasSubmitted, ...)`. The
75
+ * difference: this returns state, the workflow returns void
76
+ * (and produces side effects).
77
+ */
78
+ [PostWasSubmitted.name]: (state, event) => ({
79
+ byId: {
80
+ ...state.byId,
81
+ [event.postId]: {
82
+ postId: event.postId,
83
+ authorId: event.authorId,
84
+ body: event.body,
85
+ status: "pending",
86
+ submittedAt: event.submittedAt,
87
+ },
88
+ },
89
+ pendingIds: [...state.pendingIds, event.postId],
90
+ totals: { ...state.totals, pending: state.totals.pending + 1 },
91
+ }),
92
+
93
+ [PostWasApproved.name]: (state, event) => {
94
+ const item = state.byId[event.postId];
95
+ if (!item) return state;
96
+ return {
97
+ byId: {
98
+ ...state.byId,
99
+ [event.postId]: {
100
+ ...item,
101
+ status: "approved",
102
+ decidedAt: event.approvedAt,
103
+ decidedBy: event.approvedBy,
104
+ },
105
+ },
106
+ pendingIds: state.pendingIds.filter((id) => id !== event.postId),
107
+ totals: {
108
+ ...state.totals,
109
+ pending: state.totals.pending - 1,
110
+ approved: state.totals.approved + 1,
111
+ },
112
+ };
113
+ },
114
+
115
+ [PostWasRejected.name]: (state, event) => {
116
+ const item = state.byId[event.postId];
117
+ if (!item) return state;
118
+ return {
119
+ byId: {
120
+ ...state.byId,
121
+ [event.postId]: {
122
+ ...item,
123
+ status: "rejected",
124
+ decidedAt: event.rejectedAt,
125
+ decidedBy: event.rejectedBy,
126
+ reason: event.reason,
127
+ },
128
+ },
129
+ pendingIds: state.pendingIds.filter((id) => id !== event.postId),
130
+ totals: {
131
+ ...state.totals,
132
+ pending: state.totals.pending - 1,
133
+ rejected: state.totals.rejected + 1,
134
+ },
135
+ };
136
+ },
137
+ },
138
+ });
139
+
140
+ // ─── Queries — read the projection's state ─────────────────────
141
+
142
+ export const listPending = defineQuery(queueDashboard, {
143
+ name: "moderation.list-pending",
144
+ schema: z.object({ limit: z.coerce.number().int().min(1).max(100).default(20) }),
145
+ execute: (state, { limit }) =>
146
+ state.pendingIds
147
+ .slice(0, limit)
148
+ .map((id) => state.byId[id])
149
+ .filter((x): x is QueueItem => x !== undefined),
150
+ });
151
+
152
+ export const queueTotals = defineQuery(queueDashboard, {
153
+ name: "moderation.queue-totals",
154
+ schema: z.object({}),
155
+ execute: (state) => state.totals,
156
+ });
157
+
158
+ export const getPost = defineQuery(queueDashboard, {
159
+ name: "moderation.get-post",
160
+ schema: z.object({ postId: z.string() }),
161
+ execute: (state, { postId }) => state.byId[postId] ?? null,
162
+ });
@@ -0,0 +1,25 @@
1
+ {
2
+ "name": "{{PROJECT_NAME}}",
3
+ "version": "0.0.0",
4
+ "private": true,
5
+ "type": "module",
6
+ "scripts": {
7
+ "dev": "vite-node app/main.ts",
8
+ "test": "vitest run"
9
+ },
10
+ "dependencies": {
11
+ "@nwire/app": "^0.7.0",
12
+ "@nwire/endpoint": "^0.7.0",
13
+ "@nwire/forge": "^0.7.0",
14
+ "@nwire/http": "^0.7.0",
15
+ "@nwire/messages": "^0.7.0",
16
+ "zod": "^4.0.0"
17
+ },
18
+ "devDependencies": {
19
+ "@nwire/test-kit": "^0.7.0",
20
+ "@types/node": "^22.19.9",
21
+ "typescript": "^5.9.0",
22
+ "vite-node": "^3.2.4",
23
+ "vitest": "^4.0.18"
24
+ }
25
+ }
@@ -0,0 +1,15 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "ESNext",
5
+ "moduleResolution": "Bundler",
6
+ "lib": ["ES2022"],
7
+ "strict": true,
8
+ "esModuleInterop": true,
9
+ "skipLibCheck": true,
10
+ "forceConsistentCasingInFileNames": true,
11
+ "noEmit": true,
12
+ "types": ["node", "vite/client"]
13
+ },
14
+ "include": ["app/**/*"]
15
+ }