create-nwire 0.12.0 → 0.13.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 (71) hide show
  1. package/dist/index.d.ts +1 -1
  2. package/dist/index.js +9 -2
  3. package/package.json +1 -1
  4. package/templates/enterprise/AGENTS.md +43 -0
  5. package/templates/enterprise/__tests__/auto-moderate.test.ts +22 -6
  6. package/templates/enterprise/__tests__/submit-flow.test.ts +22 -6
  7. package/templates/enterprise/_npmrc +4 -0
  8. package/templates/enterprise/_pnpm-workspace.yaml +6 -0
  9. package/templates/enterprise/app/api.ts +3 -1
  10. package/templates/enterprise/app/app.ts +23 -15
  11. package/templates/enterprise/app/main.ts +20 -47
  12. package/templates/enterprise/config/app.ts +18 -0
  13. package/templates/enterprise/config/env.ts +32 -0
  14. package/templates/enterprise/config/http.ts +15 -0
  15. package/templates/enterprise/modules/posts/actions/approve-post.ts +4 -3
  16. package/templates/enterprise/modules/posts/actions/reject-post.ts +3 -3
  17. package/templates/enterprise/modules/posts/actions/submit-post.ts +4 -3
  18. package/templates/enterprise/modules/posts/events/post-was-approved.ts +1 -1
  19. package/templates/enterprise/modules/posts/events/post-was-rejected.ts +2 -1
  20. package/templates/enterprise/modules/posts/events/post-was-submitted.ts +2 -1
  21. package/templates/enterprise/modules/posts/projections/queue-dashboard.ts +27 -25
  22. package/templates/enterprise/modules/posts/queries/posts-by-author.ts +7 -5
  23. package/templates/enterprise/modules/posts/routes/approve-post.ts +3 -3
  24. package/templates/enterprise/modules/posts/routes/get-post.ts +3 -3
  25. package/templates/enterprise/modules/posts/routes/list-queue.ts +4 -4
  26. package/templates/enterprise/modules/posts/routes/posts-by-author.ts +28 -0
  27. package/templates/enterprise/modules/posts/routes/reject-post.ts +3 -3
  28. package/templates/enterprise/modules/posts/routes/submit-post.ts +3 -3
  29. package/templates/enterprise/modules/posts/workflows/auto-moderate.ts +3 -3
  30. package/templates/enterprise/package.json +13 -10
  31. package/templates/enterprise/tsconfig.json +1 -1
  32. package/templates/mcp/AGENTS.md +73 -0
  33. package/templates/mcp/__tests__/mcp-server.test.ts +205 -0
  34. package/templates/mcp/_gitignore +5 -0
  35. package/templates/mcp/_npmrc +4 -0
  36. package/templates/mcp/_pnpm-workspace.yaml +6 -0
  37. package/templates/mcp/app/app.ts +24 -0
  38. package/templates/mcp/app/main.ts +197 -0
  39. package/templates/mcp/app/store/facts-store.ts +17 -0
  40. package/templates/mcp/app/tools/add-fact.ts +27 -0
  41. package/templates/mcp/app/tools/list-facts.ts +17 -0
  42. package/templates/mcp/app/tools/lookup-fact.ts +28 -0
  43. package/templates/mcp/app/tools.ts +19 -0
  44. package/templates/mcp/config/app.ts +16 -0
  45. package/templates/mcp/config/env.ts +27 -0
  46. package/templates/mcp/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -0
  47. package/templates/mcp/package.json +25 -0
  48. package/templates/mcp/tsconfig.json +15 -0
  49. package/templates/mcp/vitest.config.ts +8 -0
  50. package/templates/minimal/AGENTS.md +37 -0
  51. package/templates/minimal/__tests__/hello.test.ts +3 -3
  52. package/templates/minimal/_npmrc +4 -0
  53. package/templates/minimal/_pnpm-workspace.yaml +6 -0
  54. package/templates/minimal/app/app.ts +21 -0
  55. package/templates/minimal/app/main.ts +12 -26
  56. package/templates/minimal/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -0
  57. package/templates/minimal/package.json +10 -7
  58. package/templates/service/AGENTS.md +64 -0
  59. package/templates/service/__tests__/todo-api.test.ts +5 -5
  60. package/templates/service/_npmrc +4 -0
  61. package/templates/service/_pnpm-workspace.yaml +6 -0
  62. package/templates/service/app/app.ts +25 -0
  63. package/templates/service/app/errors/todo-errors.ts +2 -2
  64. package/templates/service/app/main.ts +22 -34
  65. package/templates/service/app/resources/todo.ts +1 -1
  66. package/templates/service/app/store/todo-store.ts +9 -25
  67. package/templates/service/config/app.ts +18 -0
  68. package/templates/service/config/env.ts +32 -0
  69. package/templates/service/config/http.ts +15 -0
  70. package/templates/service/package.json +11 -8
  71. package/templates/service/tsconfig.json +1 -1
@@ -0,0 +1,197 @@
1
+ /**
2
+ * Entry — the only file that owns side effects.
3
+ *
4
+ * Boots the nwire app under the MCP adapter, then runs the JSON-RPC
5
+ * framing loop so AI clients (Claude, Cursor, IDE plugins) can drive this
6
+ * server over stdio.
7
+ *
8
+ * ┌─ STDOUT IS THE JSON-RPC CHANNEL ─────────────────────────────────┐
9
+ * │ │
10
+ * │ Every byte written to stdout MUST be a complete, newline-framed │
11
+ * │ JSON-RPC message. Non-JSON noise (console.log, banners, debug │
12
+ * │ text) breaks every MCP client silently. │
13
+ * │ │
14
+ * │ Log to stderr: console.error("msg") or process.stderr.write() │
15
+ * │ NEVER: console.log() process.stdout.write(plain text) │
16
+ * └────────────────────────────────────────────────────────────────────┘
17
+ *
18
+ * The mcpAdapter boots the nwire runtime so tools run through the normal
19
+ * dispatch path (validation, DI, observability). The stdio loop below
20
+ * translates JSON-RPC calls into adapter invocations.
21
+ *
22
+ * Tests import `{ app }` from `./app` and bypass this file entirely.
23
+ * Never import `main.ts` from a test — the stdio loop holds the process
24
+ * open and hangs the runner.
25
+ */
26
+
27
+ import { endpoint } from "@nwire/endpoint";
28
+ import { mcpAdapter } from "@nwire/mcp";
29
+ import { appConfig } from "../config/app";
30
+ import { app } from "./app";
31
+
32
+ const cfg = appConfig();
33
+
34
+ const mcp = mcpAdapter();
35
+
36
+ await endpoint(cfg.name, { banner: false, probes: { enabled: false } })
37
+ .use(mcp)
38
+ .mount(app)
39
+ .run();
40
+
41
+ // ─── JSON-RPC 2.0 stdio framing ─────────────────────────────────────
42
+
43
+ const SERVER_NAME = cfg.name;
44
+ const SERVER_VERSION = "0.0.0";
45
+
46
+ type JsonRpcResponse = {
47
+ jsonrpc: "2.0";
48
+ id: string | number | null;
49
+ result?: unknown;
50
+ error?: { code: number; message: string };
51
+ };
52
+
53
+ const send = (msg: JsonRpcResponse): void => {
54
+ process.stdout.write(JSON.stringify(msg) + "\n");
55
+ };
56
+
57
+ const log = (text: string): void => {
58
+ process.stderr.write(`[${SERVER_NAME}] ${text}\n`);
59
+ };
60
+
61
+ log("MCP server ready");
62
+
63
+ let buffer = "";
64
+
65
+ process.stdin.setEncoding("utf8");
66
+ process.stdin.on("data", (chunk: string) => {
67
+ buffer += chunk;
68
+ const lines = buffer.split(/\r?\n/);
69
+ buffer = lines.pop() ?? "";
70
+ for (const line of lines) {
71
+ if (!line.trim()) continue;
72
+ void handleLine(line);
73
+ }
74
+ });
75
+
76
+ process.stdin.on("end", () => {
77
+ log("stdin closed — exiting");
78
+ process.exit(0);
79
+ });
80
+
81
+ async function handleLine(line: string): Promise<void> {
82
+ let req: { jsonrpc: string; id?: unknown; method: string; params?: Record<string, unknown> };
83
+ try {
84
+ req = JSON.parse(line) as typeof req;
85
+ } catch {
86
+ send({ jsonrpc: "2.0", id: null, error: { code: -32700, message: "Parse error" } });
87
+ return;
88
+ }
89
+
90
+ // Notifications (no id, or notifications/* methods) must not be answered.
91
+ const isNotification = req.id === undefined || String(req.method).startsWith("notifications/");
92
+ if (isNotification) return;
93
+
94
+ const id = (req.id as string | number | null) ?? null;
95
+
96
+ try {
97
+ switch (req.method) {
98
+ case "initialize":
99
+ send({
100
+ jsonrpc: "2.0",
101
+ id,
102
+ result: {
103
+ protocolVersion: "2024-11-05",
104
+ serverInfo: { name: SERVER_NAME, version: SERVER_VERSION },
105
+ capabilities: { tools: { listChanged: false } },
106
+ },
107
+ });
108
+ break;
109
+
110
+ case "tools/list": {
111
+ const listed = mcp.list().map((t) => ({
112
+ name: t.name,
113
+ description: t.description ?? "",
114
+ inputSchema: t.inputSchema
115
+ ? zodToJsonSchema(t.inputSchema)
116
+ : { type: "object", properties: {}, additionalProperties: false },
117
+ }));
118
+ send({ jsonrpc: "2.0", id, result: { tools: listed } });
119
+ break;
120
+ }
121
+
122
+ case "tools/call": {
123
+ const name = req.params?.name as string | undefined;
124
+ const args = (req.params?.arguments ?? {}) as Record<string, unknown>;
125
+ if (!name) {
126
+ send({
127
+ jsonrpc: "2.0",
128
+ id,
129
+ error: { code: -32602, message: "tools/call requires `name`" },
130
+ });
131
+ break;
132
+ }
133
+ try {
134
+ const result = await mcp.call(name, args);
135
+ send({
136
+ jsonrpc: "2.0",
137
+ id,
138
+ result: {
139
+ content: [{ type: "text", text: JSON.stringify(result) }],
140
+ isError: false,
141
+ },
142
+ });
143
+ } catch (err) {
144
+ send({
145
+ jsonrpc: "2.0",
146
+ id,
147
+ result: {
148
+ content: [{ type: "text", text: (err as Error).message }],
149
+ isError: true,
150
+ },
151
+ });
152
+ }
153
+ break;
154
+ }
155
+
156
+ default:
157
+ send({
158
+ jsonrpc: "2.0",
159
+ id,
160
+ error: { code: -32601, message: `unknown method "${req.method}"` },
161
+ });
162
+ }
163
+ } catch (err) {
164
+ send({ jsonrpc: "2.0", id, error: { code: -32603, message: (err as Error).message } });
165
+ }
166
+ }
167
+
168
+ /**
169
+ * Minimal Zod v4 → JSON Schema converter for the tools/list response.
170
+ * Handles the shapes the MCP tools in this template actually use —
171
+ * object with string/number properties. Extend as you add new types.
172
+ *
173
+ * Zod v4 uses `_def.type` (not `_def.typeName`) and `_def.shape` is a
174
+ * plain object (not a getter function).
175
+ */
176
+ function zodToJsonSchema(schema: { _def?: unknown }): unknown {
177
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
178
+ const def = (schema as any)._def;
179
+ if (!def) return { type: "object", additionalProperties: true };
180
+
181
+ if (def.type === "object") {
182
+ const props: Record<string, unknown> = {};
183
+ const required: string[] = [];
184
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
185
+ for (const [k, v] of Object.entries(def.shape as Record<string, any>)) {
186
+ props[k] = zodToJsonSchema(v);
187
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
188
+ if ((v as any)._def?.type !== "optional") required.push(k);
189
+ }
190
+ return { type: "object", properties: props, required, additionalProperties: false };
191
+ }
192
+ if (def.type === "string") return { type: "string" };
193
+ if (def.type === "number") return { type: "number" };
194
+ if (def.type === "boolean") return { type: "boolean" };
195
+ if (def.type === "optional") return zodToJsonSchema(def.innerType);
196
+ return { type: "object", additionalProperties: true };
197
+ }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * In-memory knowledge base — a simple mutable array that all tool handlers
3
+ * share within the process. Replace this with `@nwire/drizzle` or
4
+ * `@nwire/mongo` when you need persistence across restarts.
5
+ */
6
+
7
+ export interface Fact {
8
+ id: number;
9
+ topic: string;
10
+ body: string;
11
+ }
12
+
13
+ /** Seed data — something concrete so `list-facts` returns content on first run. */
14
+ export const facts: Fact[] = [
15
+ { id: 1, topic: "nwire", body: "One handler, every transport." },
16
+ { id: 2, topic: "mcp", body: "Model Context Protocol — AI clients call tools over JSON-RPC." },
17
+ ];
@@ -0,0 +1,27 @@
1
+ /**
2
+ * add-fact — store a new fact in the knowledge base.
3
+ *
4
+ * The AI client supplies a topic and body; the tool persists it so future
5
+ * `lookup-fact` calls can surface it. Duplicate topics are allowed —
6
+ * the knowledge base treats each entry as an independent note.
7
+ */
8
+
9
+ import { z } from "zod";
10
+ import { tool } from "@nwire/wires/mcp";
11
+ import { facts } from "../store/facts-store";
12
+
13
+ const AddFactInput = z.object({
14
+ topic: z.string().min(1).max(100).describe("Short label for this fact"),
15
+ body: z.string().min(1).max(1000).describe("The fact content"),
16
+ });
17
+
18
+ export const addFactTool = tool("add-fact", {
19
+ description: "Add a new fact to the knowledge base. Returns the stored entry.",
20
+ input: AddFactInput,
21
+ });
22
+
23
+ export const addFactHandler = async (input: z.infer<typeof AddFactInput>) => {
24
+ const entry = { id: facts.length + 1, topic: input.topic, body: input.body };
25
+ facts.push(entry);
26
+ return { stored: entry, total: facts.length };
27
+ };
@@ -0,0 +1,17 @@
1
+ /**
2
+ * list-facts — enumerate every fact in the knowledge base.
3
+ *
4
+ * Useful when the AI client wants a full picture of what is stored before
5
+ * deciding which facts to update or which lookup term to use.
6
+ */
7
+
8
+ import { tool } from "@nwire/wires/mcp";
9
+ import { facts } from "../store/facts-store";
10
+
11
+ export const listFactsTool = tool("list-facts", {
12
+ description: "Return all facts currently stored in the knowledge base.",
13
+ });
14
+
15
+ export const listFactsHandler = async () => {
16
+ return { count: facts.length, facts };
17
+ };
@@ -0,0 +1,28 @@
1
+ /**
2
+ * lookup-fact — find a fact by keyword from the in-memory knowledge base.
3
+ *
4
+ * The AI client submits a search term; the tool scans all stored facts
5
+ * for matches and returns the hits. Zero results is a valid (empty) answer,
6
+ * not an error.
7
+ */
8
+
9
+ import { z } from "zod";
10
+ import { tool } from "@nwire/wires/mcp";
11
+ import { facts } from "../store/facts-store";
12
+
13
+ const LookupInput = z.object({
14
+ query: z.string().min(1).describe("Keyword or phrase to search for"),
15
+ });
16
+
17
+ export const lookupFactTool = tool("lookup-fact", {
18
+ description: "Search the knowledge base by keyword and return matching facts.",
19
+ input: LookupInput,
20
+ });
21
+
22
+ export const lookupFactHandler = async (input: z.infer<typeof LookupInput>) => {
23
+ const term = input.query.toLowerCase();
24
+ const matches = facts.filter(
25
+ (f) => f.topic.toLowerCase().includes(term) || f.body.toLowerCase().includes(term),
26
+ );
27
+ return { query: input.query, count: matches.length, results: matches };
28
+ };
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Tools — the MCP surface this app exposes.
3
+ *
4
+ * Each tool file under `./tools/` exports a `{name}Tool` (binding) +
5
+ * `{name}Handler` (function). `tools` is the flat array fed into
6
+ * `app.wire(...)` in `app.ts`.
7
+ *
8
+ * Adding a tool = one new file + one entry in this array.
9
+ */
10
+
11
+ import { lookupFactTool, lookupFactHandler } from "./tools/lookup-fact";
12
+ import { addFactTool, addFactHandler } from "./tools/add-fact";
13
+ import { listFactsTool, listFactsHandler } from "./tools/list-facts";
14
+
15
+ export const tools = [
16
+ { binding: lookupFactTool, handler: lookupFactHandler },
17
+ { binding: addFactTool, handler: addFactHandler },
18
+ { binding: listFactsTool, handler: listFactsHandler },
19
+ ] as const;
@@ -0,0 +1,16 @@
1
+ /**
2
+ * App-level settings — the process identity and lifecycle budgets the
3
+ * endpoint owns. A function of the typed `env`, so the same config file
4
+ * reads differently per environment without an `if` in sight.
5
+ */
6
+
7
+ import { env, type Env } from "./env";
8
+
9
+ export const appConfig = (e: Env = env) => ({
10
+ /** Endpoint + app name — shows in logs and telemetry. */
11
+ name: "{{PROJECT_NAME}}",
12
+ /** Print the boot banner outside of tests. */
13
+ banner: e.NODE_ENV !== "test",
14
+ });
15
+
16
+ export type AppConfig = ReturnType<typeof appConfig>;
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Typed environment — read `process.env` once, through a schema, and never
3
+ * touch it again from app code. Missing or malformed values fail loudly at
4
+ * boot with a readable message, not three layers deep at first request.
5
+ */
6
+
7
+ import { z } from "zod";
8
+
9
+ const EnvSchema = z.object({
10
+ NODE_ENV: z.enum(["development", "test", "production"]).default("development"),
11
+ });
12
+
13
+ export type Env = z.output<typeof EnvSchema>;
14
+
15
+ export function loadEnv(source: NodeJS.ProcessEnv = process.env): Env {
16
+ const parsed = EnvSchema.safeParse(source);
17
+ if (!parsed.success) {
18
+ const summary = parsed.error.issues
19
+ .map((i) => ` ${i.path.join(".") || "(root)"}: ${i.message}`)
20
+ .join("\n");
21
+ throw new Error(`Invalid environment:\n${summary}`);
22
+ }
23
+ return parsed.data;
24
+ }
25
+
26
+ /** The validated environment, loaded once. */
27
+ export const env: Env = loadEnv();
@@ -0,0 +1 @@
1
+ {"version":"4.1.6","results":[[":__tests__/mcp-server.test.ts",{"duration":316.55000000000007,"failed":false}]]}
@@ -0,0 +1,25 @@
1
+ {
2
+ "name": "{{PROJECT_NAME}}",
3
+ "version": "0.0.0",
4
+ "private": true,
5
+ "type": "module",
6
+ "scripts": {
7
+ "start": "node --import tsx/esm app/main.ts",
8
+ "dev": "node --import tsx/esm --watch app/main.ts",
9
+ "test": "vitest run",
10
+ "typecheck": "tsc --noEmit"
11
+ },
12
+ "dependencies": {
13
+ "@nwire/app": "^0.13.0",
14
+ "@nwire/endpoint": "^0.13.0",
15
+ "@nwire/mcp": "^0.13.0",
16
+ "@nwire/wires": "^0.13.0",
17
+ "zod": "^4.0.0"
18
+ },
19
+ "devDependencies": {
20
+ "@types/node": "^22.19.9",
21
+ "tsx": "^4.19.4",
22
+ "typescript": "^5.9.0",
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"]
13
+ },
14
+ "include": ["app/**/*", "config/**/*", "__tests__/**/*"]
15
+ }
@@ -0,0 +1,8 @@
1
+ import { defineConfig } from "vitest/config";
2
+
3
+ export default defineConfig({
4
+ test: {
5
+ environment: "node",
6
+ include: ["__tests__/**/*.{test,spec}.ts"],
7
+ },
8
+ });
@@ -0,0 +1,37 @@
1
+ # AGENTS.md — working in this nwire app
2
+
3
+ This is a **minimal nwire** backend. You write logic once as a **handler**
4
+ (a plain typed function) and wire it to a **transport** (here, HTTP). The
5
+ handler never knows which transport called it.
6
+
7
+ ## The shape (`app/main.ts`)
8
+
9
+ ```ts
10
+ import { createApp } from "@nwire/app";
11
+ import { endpoint } from "@nwire/endpoint";
12
+ import { get } from "@nwire/wires/http";
13
+ import { httpKoa } from "@nwire/koa";
14
+
15
+ const app = createApp({ appName: "app" });
16
+ app.wire(get("/hello"), async () => ({ message: "hello" }));
17
+ await endpoint("app", { port: 3000 }).use(httpKoa()).mount(app).run();
18
+ ```
19
+
20
+ ## What's installed (don't import beyond these)
21
+
22
+ | Primitive | Package |
23
+ | -------------------------------- | ------------------- |
24
+ | `createApp`, `definePlugin` | `@nwire/app` |
25
+ | `endpoint` | `@nwire/endpoint` |
26
+ | `get` `post` `put` `patch` `del` | `@nwire/wires/http` |
27
+ | `httpKoa` | `@nwire/koa` |
28
+
29
+ This tier is HTTP-only. Add capabilities by installing the package first:
30
+ `@nwire/handler` for `defineResource`/`defineError`, `@nwire/messages` for
31
+ `defineEvent`, `@nwire/forge` for actions/actors/projections/workflows.
32
+ **Don't import a primitive from a package that isn't in `package.json`.**
33
+
34
+ ## Commands
35
+
36
+ - `pnpm dev` — run the app (HTTP on :3000). `pnpm test` — tests.
37
+ - `pnpm doctor` — health-check the setup. `pnpm studio` — trace console.
@@ -3,19 +3,19 @@
3
3
  * empty `name` is rejected with a structured validation error.
4
4
  *
5
5
  * Boots the app through endpoint + httpKoa on an ephemeral port.
6
+ * Imports `{ app }` from `../app/app` (the pure value). Never imports
7
+ * `main.ts` — that file boots a real server and hangs the test runner.
6
8
  */
7
9
 
8
10
  import { describe, it, expect, beforeAll, afterAll } from "vitest";
9
11
  import { endpoint } from "@nwire/endpoint";
10
12
  import { httpKoa } from "@nwire/koa";
11
- import { buildApp } from "../app/main";
13
+ import { app } from "../app/app";
12
14
 
13
15
  let running: Awaited<ReturnType<ReturnType<typeof endpoint>["run"]>>;
14
- let app: ReturnType<typeof buildApp>;
15
16
  let url: string;
16
17
 
17
18
  beforeAll(async () => {
18
- app = buildApp();
19
19
  const koa = httpKoa({ port: 0 });
20
20
  running = await endpoint("hello-test", {
21
21
  exitOnShutdown: false,
@@ -0,0 +1,4 @@
1
+ # nwire dev runs via vite-node (esbuild). pnpm 11 defers esbuild's build
2
+ # script and then fails the pre-run deps check; skip that check so
3
+ # `pnpm dev` runs clean right after install.
4
+ verify-deps-before-run=false
@@ -0,0 +1,6 @@
1
+ # pnpm 11 reads build-allow + run settings here (not the package.json
2
+ # "pnpm" field). esbuild backs vite-node; allow its build, and skip the
3
+ # pre-run deps check so `pnpm dev` runs clean right after install.
4
+ onlyBuiltDependencies:
5
+ - esbuild
6
+ verifyDepsBeforeRun: false
@@ -0,0 +1,21 @@
1
+ /**
2
+ * App — the composition root.
3
+ *
4
+ * `createApp` constructs the container and wires all routes in this module
5
+ * body. The exported `app` is COMPLETE on import — no caller needs to wire
6
+ * or start it. No ports are bound here; that is `main.ts`'s job.
7
+ *
8
+ * Tests import `{ app }` from this file and build an isolated endpoint.
9
+ * Never import `main.ts` from tests: it boots a server and hangs the runner.
10
+ */
11
+
12
+ import { createApp } from "@nwire/app";
13
+ import { wires } from "./api";
14
+
15
+ const _app = createApp({ appName: "{{PROJECT_NAME}}" });
16
+
17
+ for (const { binding, handler } of wires) {
18
+ _app.wire(binding, handler);
19
+ }
20
+
21
+ export const app = _app;
@@ -1,35 +1,21 @@
1
1
  /**
2
- * Entry — boot the app under HTTP.
2
+ * Entry — the only file that boots a real HTTP server.
3
3
  *
4
- * createApp(...) — the bounded context (container + wires)
5
- * app.wire(...) — pair a binding with its handler
6
- * endpoint().use(...) — install a transport adapter (HTTP, queue, MCP, …)
7
- * .mount(app).run() — boot the app under the adapters; ready for traffic
4
+ * pnpm dev
8
5
  *
9
- * The minimal shape: no DI, no forge, no domain primitives. Two packages
10
- * and three files. Graduate to `service` when you need a container, store,
11
- * or structured errors.
6
+ * curl -X POST http://localhost:3000/hello \
7
+ * -H "content-type: application/json" \
8
+ * -d '{"name":"Alice"}'
12
9
  *
13
- * Run: pnpm dev
14
- * Try: curl -X POST http://localhost:3000/hello \
15
- * -H "content-type: application/json" \
16
- * -d '{"name":"Alice"}'
10
+ * `app` is a pure value (no side effects on import — see `./app`). This
11
+ * file adds the one side effect: binding a port and serving traffic.
12
+ * Tests import `{ app }` from `./app` and build their own ephemeral
13
+ * endpoint — never import this file from tests or it boots a server and
14
+ * hangs the runner.
17
15
  */
18
16
 
19
- import { createApp } from "@nwire/app";
20
17
  import { endpoint } from "@nwire/endpoint";
21
18
  import { httpKoa } from "@nwire/koa";
22
- import { wires } from "./api";
19
+ import { app } from "./app";
23
20
 
24
- export function buildApp() {
25
- const app = createApp({ appName: "{{PROJECT_NAME}}" });
26
- for (const { binding, handler } of wires) {
27
- app.wire(binding, handler);
28
- }
29
- return app;
30
- }
31
-
32
- if (import.meta.url === `file://${process.argv[1]}`) {
33
- const app = buildApp();
34
- await endpoint("{{PROJECT_NAME}}", { port: 3000 }).use(httpKoa()).mount(app).run();
35
- }
21
+ await endpoint("{{PROJECT_NAME}}", { port: 3000 }).use(httpKoa()).mount(app).run();
@@ -0,0 +1 @@
1
+ {"version":"4.1.6","results":[[":__tests__/hello.test.ts",{"duration":38.90391699999998,"failed":false}]]}
@@ -4,20 +4,23 @@
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "scripts": {
7
- "dev": "vite-node app/main.ts",
8
- "test": "vitest run"
7
+ "dev": "nwire dev",
8
+ "test": "vitest run",
9
+ "doctor": "nwire doctor",
10
+ "studio": "nwire studio",
11
+ "cache": "nwire cache"
9
12
  },
10
13
  "dependencies": {
11
- "@nwire/app": "^0.12.0",
12
- "@nwire/endpoint": "^0.12.0",
13
- "@nwire/koa": "^0.12.0",
14
- "@nwire/wires": "^0.12.0",
14
+ "@nwire/app": "^0.13.0",
15
+ "@nwire/endpoint": "^0.13.0",
16
+ "@nwire/koa": "^0.13.0",
17
+ "@nwire/wires": "^0.13.0",
15
18
  "zod": "^4.0.0"
16
19
  },
17
20
  "devDependencies": {
21
+ "@nwire/cli": "^0.13.0",
18
22
  "@types/node": "^22.19.9",
19
23
  "typescript": "^5.9.0",
20
- "vite-node": "^3.2.4",
21
24
  "vitest": "^4.0.18"
22
25
  }
23
26
  }
@@ -0,0 +1,64 @@
1
+ # AGENTS.md — working in this nwire app
2
+
3
+ This is an **nwire** backend. The one idea: you write logic once as a
4
+ **handler** (a plain typed function) and wire it to a **transport** (an
5
+ HTTP route, a queue, cron, an MCP tool). The handler never knows which
6
+ transport called it.
7
+
8
+ ## The shape
9
+
10
+ ```ts
11
+ import { createApp } from "@nwire/app";
12
+ import { endpoint } from "@nwire/endpoint";
13
+ import { post } from "@nwire/wires/http";
14
+ import { httpKoa } from "@nwire/koa";
15
+
16
+ const app = createApp({ appName: "svc" });
17
+ app.wire(post("/things", { body: z.object({ name: z.string() }) }), async (input) => ({
18
+ ok: input.name,
19
+ }));
20
+ await endpoint("svc", { port: 3000 }).use(httpKoa()).mount(app).run();
21
+ ```
22
+
23
+ `app/main.ts` builds and runs the app. Routes live in `app/routes/`,
24
+ their request shapes in `app/resources/`, errors in `app/errors/`.
25
+
26
+ ## Import map — DO NOT GUESS THESE
27
+
28
+ The single most common mistake is importing a primitive from the wrong
29
+ package. Use exactly:
30
+
31
+ | Primitive | Package |
32
+ | --------------------------------------------------------------------------------------------------------------- | ------------------- |
33
+ | `createApp`, `appCompose`, `definePlugin` | `@nwire/app` |
34
+ | `endpoint` | `@nwire/endpoint` |
35
+ | `get` `post` `put` `patch` `del` | `@nwire/wires/http` |
36
+ | `httpKoa` | `@nwire/koa` |
37
+ | `defineHandler`, `defineResource`, `defineError`, `Unauthorized`/`Forbidden`/`NotFound`/`Conflict`/`BadRequest` | `@nwire/handler` |
38
+ | `defineEvent` | `@nwire/messages` |
39
+ | `defineAction`, `defineActor`, `defineProjection`, `defineQuery`, `defineWorkflow`, `forgePlugins` | `@nwire/forge` |
40
+
41
+ `defineResource` and `defineError` are in **`@nwire/handler`**, not
42
+ `@nwire/forge`. This service template does not depend on `@nwire/forge` —
43
+ don't import from it unless you add it to `package.json` first.
44
+
45
+ ## Adding a route
46
+
47
+ 1. A binding + handler: `app/routes/<verb>-<noun>.ts` exports a route
48
+ (`post("/path", { body })`) and a handler `(input, ctx) => result`.
49
+ 2. Register it in `app/api.ts`'s wire list (or `app.wire(route, handler)`).
50
+ 3. Throw a typed `defineError` for failures; return a plain object or a
51
+ `defineResource` projection for success.
52
+
53
+ ## Verbs
54
+
55
+ `.wire(binding, handler)` binds a transport. `.use(adapter)` mounts a
56
+ transport on the endpoint. To react to events, use `when(Event, fn)` in a
57
+ listener file (not `on` — `on` is for lifecycle hooks).
58
+
59
+ ## Commands
60
+
61
+ - `pnpm dev` — run the app (HTTP on :3000).
62
+ - `pnpm test` — run the test suite.
63
+ - `pnpm doctor` — health-check the project setup.
64
+ - `pnpm studio` — open the live trace/inspect console.