create-agent-rig 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.
Files changed (146) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +87 -0
  3. package/package.json +54 -0
  4. package/packages/cli/dist/commands/create.js +109 -0
  5. package/packages/cli/dist/index.js +102 -0
  6. package/packages/cli/dist/lib/colors.js +14 -0
  7. package/packages/cli/dist/lib/composition.js +20 -0
  8. package/packages/cli/dist/lib/copy-tree.js +91 -0
  9. package/packages/cli/dist/lib/prompts.js +24 -0
  10. package/packages/cli/dist/lib/substitute.js +22 -0
  11. package/packages/cli/dist/lib/summary.js +41 -0
  12. package/packages/cli/dist/lib/targets.js +14 -0
  13. package/packages/cli/dist/templates.js +21 -0
  14. package/scripts/prepare.mjs +29 -0
  15. package/templates/agent-os/stack/aws-cdk/.claude/agents/cdk-diff-reviewer.md +49 -0
  16. package/templates/agent-os/stack/aws-cdk/.claude/rules/aws-cdk.md +59 -0
  17. package/templates/agent-os/stack/aws-cdk/.claude/skills/post-deploy-verify/SKILL.md +51 -0
  18. package/templates/agent-os/stack/node-ts/.claude/rules/node-ts.md +39 -0
  19. package/templates/agent-os/universal/.claude/agents/code-reviewer.md +36 -0
  20. package/templates/agent-os/universal/.claude/agents/security-scanner.md +40 -0
  21. package/templates/agent-os/universal/.claude/agents/test-writer.md +36 -0
  22. package/templates/agent-os/universal/.claude/hooks/block-no-verify.mjs +43 -0
  23. package/templates/agent-os/universal/.claude/hooks/guard-core-purity.mjs +79 -0
  24. package/templates/agent-os/universal/.claude/hooks/guard-web-boundary.mjs +53 -0
  25. package/templates/agent-os/universal/.claude/rules/architecture.md +74 -0
  26. package/templates/agent-os/universal/.claude/rules/autonomy.md +81 -0
  27. package/templates/agent-os/universal/.claude/rules/workflow.md +62 -0
  28. package/templates/agent-os/universal/.claude/settings.json +28 -0
  29. package/templates/agent-os/universal/.claude/skills/pr-ship/SKILL.md +53 -0
  30. package/templates/agent-os/universal/CLAUDE.md +62 -0
  31. package/templates/skeleton/aws-serverless/.github/workflows/ci.yml +23 -0
  32. package/templates/skeleton/aws-serverless/README.md +78 -0
  33. package/templates/skeleton/aws-serverless/apps/web/next.config.mjs +17 -0
  34. package/templates/skeleton/aws-serverless/apps/web/package.json +19 -0
  35. package/templates/skeleton/aws-serverless/apps/web/src/app/layout.tsx +17 -0
  36. package/templates/skeleton/aws-serverless/apps/web/src/app/page.tsx +96 -0
  37. package/templates/skeleton/aws-serverless/apps/web/src/lib/api.ts +29 -0
  38. package/templates/skeleton/aws-serverless/apps/web/src/lib/validate.ts +23 -0
  39. package/templates/skeleton/aws-serverless/apps/web/test/shared-validation.test.ts +38 -0
  40. package/templates/skeleton/aws-serverless/apps/web/tsconfig.json +14 -0
  41. package/templates/skeleton/aws-serverless/eslint.config.mjs +20 -0
  42. package/templates/skeleton/aws-serverless/gitignore +9 -0
  43. package/templates/skeleton/aws-serverless/infra/bin/app.ts +19 -0
  44. package/templates/skeleton/aws-serverless/infra/cdk.json +3 -0
  45. package/templates/skeleton/aws-serverless/infra/lib/app-stack.ts +116 -0
  46. package/templates/skeleton/aws-serverless/infra/lib/web-stack.ts +32 -0
  47. package/templates/skeleton/aws-serverless/infra/package.json +18 -0
  48. package/templates/skeleton/aws-serverless/infra/test/app-stack.test.ts +104 -0
  49. package/templates/skeleton/aws-serverless/infra/test/web-stack.test.ts +41 -0
  50. package/templates/skeleton/aws-serverless/package.json +30 -0
  51. package/templates/skeleton/aws-serverless/packages/core/package.json +11 -0
  52. package/templates/skeleton/aws-serverless/packages/core/src/events.ts +14 -0
  53. package/templates/skeleton/aws-serverless/packages/core/src/index.ts +15 -0
  54. package/templates/skeleton/aws-serverless/packages/core/src/note.ts +69 -0
  55. package/templates/skeleton/aws-serverless/packages/core/test/events.test.ts +23 -0
  56. package/templates/skeleton/aws-serverless/packages/core/test/note.test.ts +101 -0
  57. package/templates/skeleton/aws-serverless/packages/db/package.json +14 -0
  58. package/templates/skeleton/aws-serverless/packages/db/src/client.ts +17 -0
  59. package/templates/skeleton/aws-serverless/packages/db/src/index.ts +2 -0
  60. package/templates/skeleton/aws-serverless/packages/db/src/note-model.ts +52 -0
  61. package/templates/skeleton/aws-serverless/packages/db/test/note-model.test.ts +91 -0
  62. package/templates/skeleton/aws-serverless/packages/shared/package.json +11 -0
  63. package/templates/skeleton/aws-serverless/packages/shared/src/env.ts +17 -0
  64. package/templates/skeleton/aws-serverless/packages/shared/src/errors.ts +33 -0
  65. package/templates/skeleton/aws-serverless/packages/shared/src/index.ts +3 -0
  66. package/templates/skeleton/aws-serverless/packages/shared/src/logger.ts +20 -0
  67. package/templates/skeleton/aws-serverless/packages/shared/test/env.test.ts +26 -0
  68. package/templates/skeleton/aws-serverless/packages/shared/test/errors.test.ts +28 -0
  69. package/templates/skeleton/aws-serverless/packages/shared/test/logger.test.ts +19 -0
  70. package/templates/skeleton/aws-serverless/pnpm-lock.yaml +2855 -0
  71. package/templates/skeleton/aws-serverless/pnpm-workspace.yaml +14 -0
  72. package/templates/skeleton/aws-serverless/services/api/package.json +15 -0
  73. package/templates/skeleton/aws-serverless/services/api/src/adapters/sqs-publisher.ts +26 -0
  74. package/templates/skeleton/aws-serverless/services/api/src/handlers/create-note.ts +42 -0
  75. package/templates/skeleton/aws-serverless/services/api/src/handlers/list-notes.ts +24 -0
  76. package/templates/skeleton/aws-serverless/services/api/src/list-main.ts +12 -0
  77. package/templates/skeleton/aws-serverless/services/api/src/main.ts +21 -0
  78. package/templates/skeleton/aws-serverless/services/api/src/usecases/create-note.ts +30 -0
  79. package/templates/skeleton/aws-serverless/services/api/src/usecases/list-notes.ts +14 -0
  80. package/templates/skeleton/aws-serverless/services/api/test/create-note.handler.test.ts +92 -0
  81. package/templates/skeleton/aws-serverless/services/api/test/create-note.usecase.test.ts +45 -0
  82. package/templates/skeleton/aws-serverless/services/api/test/list-notes.test.ts +51 -0
  83. package/templates/skeleton/aws-serverless/services/api/test/sqs-publisher.test.ts +22 -0
  84. package/templates/skeleton/aws-serverless/services/worker/package.json +12 -0
  85. package/templates/skeleton/aws-serverless/services/worker/src/handlers/note-created.ts +15 -0
  86. package/templates/skeleton/aws-serverless/services/worker/src/main.ts +7 -0
  87. package/templates/skeleton/aws-serverless/services/worker/src/usecases/process-note-created.ts +37 -0
  88. package/templates/skeleton/aws-serverless/services/worker/test/note-created.test.ts +61 -0
  89. package/templates/skeleton/aws-serverless/tsconfig.base.json +15 -0
  90. package/templates/skeleton/aws-serverless/tsconfig.json +16 -0
  91. package/templates/skeleton/aws-serverless/vitest.config.ts +14 -0
  92. package/templates/skeleton/node-service/.github/workflows/ci.yml +22 -0
  93. package/templates/skeleton/node-service/README.md +74 -0
  94. package/templates/skeleton/node-service/apps/web/next.config.mjs +17 -0
  95. package/templates/skeleton/node-service/apps/web/package.json +19 -0
  96. package/templates/skeleton/node-service/apps/web/src/app/layout.tsx +17 -0
  97. package/templates/skeleton/node-service/apps/web/src/app/page.tsx +96 -0
  98. package/templates/skeleton/node-service/apps/web/src/lib/api.ts +29 -0
  99. package/templates/skeleton/node-service/apps/web/src/lib/validate.ts +23 -0
  100. package/templates/skeleton/node-service/apps/web/test/shared-validation.test.ts +38 -0
  101. package/templates/skeleton/node-service/apps/web/tsconfig.json +14 -0
  102. package/templates/skeleton/node-service/eslint.config.mjs +20 -0
  103. package/templates/skeleton/node-service/gitignore +9 -0
  104. package/templates/skeleton/node-service/package.json +28 -0
  105. package/templates/skeleton/node-service/packages/core/package.json +11 -0
  106. package/templates/skeleton/node-service/packages/core/src/events.ts +14 -0
  107. package/templates/skeleton/node-service/packages/core/src/index.ts +15 -0
  108. package/templates/skeleton/node-service/packages/core/src/note.ts +69 -0
  109. package/templates/skeleton/node-service/packages/core/test/events.test.ts +23 -0
  110. package/templates/skeleton/node-service/packages/core/test/note.test.ts +101 -0
  111. package/templates/skeleton/node-service/packages/db/package.json +12 -0
  112. package/templates/skeleton/node-service/packages/db/src/index.ts +1 -0
  113. package/templates/skeleton/node-service/packages/db/src/note-store.ts +63 -0
  114. package/templates/skeleton/node-service/packages/db/test/note-store.test.ts +80 -0
  115. package/templates/skeleton/node-service/packages/shared/package.json +11 -0
  116. package/templates/skeleton/node-service/packages/shared/src/env.ts +17 -0
  117. package/templates/skeleton/node-service/packages/shared/src/errors.ts +33 -0
  118. package/templates/skeleton/node-service/packages/shared/src/index.ts +3 -0
  119. package/templates/skeleton/node-service/packages/shared/src/logger.ts +20 -0
  120. package/templates/skeleton/node-service/packages/shared/test/env.test.ts +26 -0
  121. package/templates/skeleton/node-service/packages/shared/test/errors.test.ts +28 -0
  122. package/templates/skeleton/node-service/packages/shared/test/logger.test.ts +19 -0
  123. package/templates/skeleton/node-service/pnpm-lock.yaml +2399 -0
  124. package/templates/skeleton/node-service/pnpm-workspace.yaml +13 -0
  125. package/templates/skeleton/node-service/services/api/package.json +17 -0
  126. package/templates/skeleton/node-service/services/api/src/adapters/spool-publisher.ts +23 -0
  127. package/templates/skeleton/node-service/services/api/src/handlers/create-note.ts +40 -0
  128. package/templates/skeleton/node-service/services/api/src/handlers/list-notes.ts +23 -0
  129. package/templates/skeleton/node-service/services/api/src/main.ts +47 -0
  130. package/templates/skeleton/node-service/services/api/src/server.ts +89 -0
  131. package/templates/skeleton/node-service/services/api/src/usecases/create-note.ts +30 -0
  132. package/templates/skeleton/node-service/services/api/src/usecases/list-notes.ts +14 -0
  133. package/templates/skeleton/node-service/services/api/test/create-note.handler.test.ts +64 -0
  134. package/templates/skeleton/node-service/services/api/test/create-note.usecase.test.ts +43 -0
  135. package/templates/skeleton/node-service/services/api/test/list-notes.test.ts +48 -0
  136. package/templates/skeleton/node-service/services/api/test/server.test.ts +123 -0
  137. package/templates/skeleton/node-service/services/api/test/spool-publisher.test.ts +32 -0
  138. package/templates/skeleton/node-service/services/worker/package.json +16 -0
  139. package/templates/skeleton/node-service/services/worker/src/main.ts +28 -0
  140. package/templates/skeleton/node-service/services/worker/src/spool.ts +60 -0
  141. package/templates/skeleton/node-service/services/worker/src/usecases/process-note-created.ts +38 -0
  142. package/templates/skeleton/node-service/services/worker/test/process-note-created.test.ts +34 -0
  143. package/templates/skeleton/node-service/services/worker/test/spool.test.ts +76 -0
  144. package/templates/skeleton/node-service/tsconfig.base.json +15 -0
  145. package/templates/skeleton/node-service/tsconfig.json +13 -0
  146. package/templates/skeleton/node-service/vitest.config.ts +12 -0
@@ -0,0 +1,13 @@
1
+ packages:
2
+ - packages/*
3
+ - services/*
4
+ - apps/*
5
+
6
+ # esbuild arrives via tsx. Its postinstall is intentionally NOT approved:
7
+ # skipping it keeps the JS launcher (which resolves the platform binary
8
+ # itself); approving it swaps in a raw binary that pnpm's bin shim can't run.
9
+ # sharp (via next) only matters for the image optimizer, which a static
10
+ # export never runs — its native build stays off.
11
+ allowBuilds:
12
+ esbuild: false
13
+ sharp: false
@@ -0,0 +1,17 @@
1
+ {
2
+ "name": "@app/api",
3
+ "version": "0.1.0",
4
+ "private": true,
5
+ "type": "module",
6
+ "main": "src/main.ts",
7
+ "types": "src/main.ts",
8
+ "scripts": {
9
+ "start": "tsx src/main.ts"
10
+ },
11
+ "dependencies": {
12
+ "@app/core": "workspace:*",
13
+ "@app/db": "workspace:*",
14
+ "@app/shared": "workspace:*",
15
+ "zod": "^4.4.3"
16
+ }
17
+ }
@@ -0,0 +1,23 @@
1
+ // The queue between api and worker is a spool directory: one JSON file per
2
+ // event. Same DLQ discipline as any queue — the worker moves poison files to
3
+ // the DLQ directory after its retry budget.
4
+ import { mkdir, rename, writeFile } from 'node:fs/promises';
5
+ import path from 'node:path';
6
+ import type { NoteCreatedEvent } from '@app/core';
7
+ import type { EventPublisher } from '../usecases/create-note.js';
8
+
9
+ export class SpoolEventPublisher implements EventPublisher {
10
+ constructor(
11
+ private readonly queueDir: string,
12
+ /** Unique, sortable message name — injected so tests stay deterministic. */
13
+ private readonly nextName: () => string,
14
+ ) {}
15
+
16
+ async publish(event: NoteCreatedEvent): Promise<void> {
17
+ await mkdir(this.queueDir, { recursive: true });
18
+ const file = path.join(this.queueDir, `${this.nextName()}.json`);
19
+ // Write-then-rename so the worker never picks up a half-written message.
20
+ await writeFile(`${file}.tmp`, JSON.stringify(event));
21
+ await rename(`${file}.tmp`, file);
22
+ }
23
+ }
@@ -0,0 +1,40 @@
1
+ // The handler translates transport ⇄ domain and nothing else: decode the
2
+ // payload, call the usecase, map the result (or the typed error) to HTTP.
3
+ import { InvalidNoteError } from '@app/core';
4
+ import { AppError } from '@app/shared';
5
+ import { createNoteUsecase, type CreateNoteDeps } from '../usecases/create-note.js';
6
+
7
+ export interface HttpResponse {
8
+ statusCode: number;
9
+ body: string;
10
+ }
11
+
12
+ const json = (statusCode: number, body: unknown): HttpResponse => ({
13
+ statusCode,
14
+ body: JSON.stringify(body),
15
+ });
16
+
17
+ export function makeCreateNoteHandler(deps: CreateNoteDeps) {
18
+ return async (rawBody: string): Promise<HttpResponse> => {
19
+ let payload: unknown;
20
+ try {
21
+ payload = JSON.parse(rawBody);
22
+ } catch {
23
+ return json(400, { error: 'body must be valid JSON' });
24
+ }
25
+
26
+ try {
27
+ const note = await createNoteUsecase(payload, deps);
28
+ return json(201, { note });
29
+ } catch (error) {
30
+ if (error instanceof InvalidNoteError) {
31
+ return json(400, { error: 'invalid note', issues: error.issues });
32
+ }
33
+ if (error instanceof AppError) {
34
+ return json(error.statusCode, { error: error.message, code: error.code });
35
+ }
36
+ deps.log.error('unhandled error in create-note', { error: String(error) });
37
+ return json(500, { error: 'internal error' });
38
+ }
39
+ };
40
+ }
@@ -0,0 +1,23 @@
1
+ import { AppError } from '@app/shared';
2
+ import type { Logger } from '@app/shared';
3
+ import { listNotesUsecase, type ListNotesDeps } from '../usecases/list-notes.js';
4
+ import type { HttpResponse } from './create-note.js';
5
+
6
+ const json = (statusCode: number, body: unknown): HttpResponse => ({
7
+ statusCode,
8
+ body: JSON.stringify(body),
9
+ });
10
+
11
+ export function makeListNotesHandler(deps: ListNotesDeps & { log: Logger }) {
12
+ return async (): Promise<HttpResponse> => {
13
+ try {
14
+ return json(200, { notes: await listNotesUsecase(deps) });
15
+ } catch (error) {
16
+ if (error instanceof AppError) {
17
+ return json(error.statusCode, { error: error.message, code: error.code });
18
+ }
19
+ deps.log.error('unhandled error in list-notes', { error: String(error) });
20
+ return json(500, { error: 'internal error' });
21
+ }
22
+ };
23
+ }
@@ -0,0 +1,47 @@
1
+ // Composition root — the ONLY file with real wiring. Tests target the
2
+ // factories with stub deps and never import this module.
3
+ import { randomUUID } from 'node:crypto';
4
+ import path from 'node:path';
5
+ import { z } from 'zod';
6
+ import { JsonFileNoteStore } from '@app/db';
7
+ import { createLogger, loadEnv } from '@app/shared';
8
+ import { SpoolEventPublisher } from './adapters/spool-publisher.js';
9
+ import { makeServer } from './server.js';
10
+
11
+ // The built web bundle (pnpm build:web) lives here; served when present.
12
+ const defaultStaticDir = path.resolve(
13
+ path.dirname(new URL(import.meta.url).pathname),
14
+ '..',
15
+ '..',
16
+ '..',
17
+ 'apps',
18
+ 'web',
19
+ 'out',
20
+ );
21
+
22
+ const env = loadEnv(
23
+ z.object({
24
+ PORT: z.coerce.number().int().positive().default(3000),
25
+ DATA_DIR: z.string().min(1).default('var/data'),
26
+ QUEUE_DIR: z.string().min(1).default('var/queue'),
27
+ STATIC_DIR: z.string().min(1).default(defaultStaticDir),
28
+ }),
29
+ process.env,
30
+ );
31
+
32
+ const log = createLogger({ service: 'api' });
33
+
34
+ const server = makeServer(
35
+ {
36
+ notes: new JsonFileNoteStore(path.join(env.DATA_DIR, 'notes.json')),
37
+ events: new SpoolEventPublisher(env.QUEUE_DIR, () => `${Date.now()}-${randomUUID()}`),
38
+ newId: () => randomUUID(),
39
+ now: () => new Date().toISOString(),
40
+ log,
41
+ },
42
+ { staticDir: env.STATIC_DIR },
43
+ );
44
+
45
+ server.listen(env.PORT, () => {
46
+ log.info('api listening', { port: env.PORT });
47
+ });
@@ -0,0 +1,89 @@
1
+ // Thin HTTP shell around the handlers: routing, body collection, and (when
2
+ // configured) serving the built web bundle. No second runtime for the
3
+ // frontend — the same process serves the static export.
4
+ import { createReadStream } from 'node:fs';
5
+ import { stat } from 'node:fs/promises';
6
+ import { createServer, type Server } from 'node:http';
7
+ import path from 'node:path';
8
+ import type { CreateNoteDeps } from './usecases/create-note.js';
9
+ import type { ListNotesDeps } from './usecases/list-notes.js';
10
+ import { makeCreateNoteHandler } from './handlers/create-note.js';
11
+ import { makeListNotesHandler } from './handlers/list-notes.js';
12
+ import type { HttpResponse } from './handlers/create-note.js';
13
+
14
+ export type ServerDeps = CreateNoteDeps & ListNotesDeps;
15
+
16
+ export interface ServerOptions {
17
+ /** Directory of the built web bundle (apps/web/out). Unset = API only. */
18
+ staticDir?: string;
19
+ }
20
+
21
+ const CONTENT_TYPES: Record<string, string> = {
22
+ '.html': 'text/html; charset=utf-8',
23
+ '.css': 'text/css',
24
+ '.js': 'text/javascript',
25
+ '.json': 'application/json',
26
+ '.svg': 'image/svg+xml',
27
+ '.png': 'image/png',
28
+ '.ico': 'image/x-icon',
29
+ '.txt': 'text/plain; charset=utf-8',
30
+ };
31
+
32
+ export function makeServer(deps: ServerDeps, options: ServerOptions = {}): Server {
33
+ const createNote = makeCreateNoteHandler(deps);
34
+ const listNotes = makeListNotesHandler(deps);
35
+
36
+ return createServer((request, response) => {
37
+ const respond = (result: HttpResponse) => {
38
+ response.writeHead(result.statusCode, { 'content-type': 'application/json' });
39
+ response.end(result.body);
40
+ };
41
+ const notFound = () => respond({ statusCode: 404, body: JSON.stringify({ error: 'not found' }) });
42
+
43
+ if (request.method === 'POST' && request.url === '/notes') {
44
+ const chunks: Buffer[] = [];
45
+ request.on('data', (chunk: Buffer) => chunks.push(chunk));
46
+ request.on('end', () => {
47
+ void createNote(Buffer.concat(chunks).toString('utf8')).then(respond);
48
+ });
49
+ return;
50
+ }
51
+
52
+ if (request.method === 'GET' && request.url === '/notes') {
53
+ void listNotes().then(respond);
54
+ return;
55
+ }
56
+
57
+ if (request.method === 'GET' && options.staticDir) {
58
+ void serveStatic(options.staticDir, request.url ?? '/', response, notFound);
59
+ return;
60
+ }
61
+
62
+ notFound();
63
+ });
64
+ }
65
+
66
+ async function serveStatic(
67
+ staticDir: string,
68
+ rawUrl: string,
69
+ response: import('node:http').ServerResponse,
70
+ notFound: () => void,
71
+ ): Promise<void> {
72
+ const pathname = decodeURIComponent(new URL(rawUrl, 'http://local').pathname);
73
+ const relative = pathname.endsWith('/') ? `${pathname}index.html` : pathname;
74
+ const resolved = path.normalize(path.join(staticDir, relative));
75
+ // Path-traversal guard: whatever the URL said, we never leave staticDir.
76
+ if (!resolved.startsWith(path.normalize(staticDir) + path.sep)) {
77
+ return notFound();
78
+ }
79
+ try {
80
+ const stats = await stat(resolved);
81
+ if (!stats.isFile()) return notFound();
82
+ } catch {
83
+ return notFound();
84
+ }
85
+ response.writeHead(200, {
86
+ 'content-type': CONTENT_TYPES[path.extname(resolved)] ?? 'application/octet-stream',
87
+ });
88
+ createReadStream(resolved).pipe(response);
89
+ }
@@ -0,0 +1,30 @@
1
+ // The usecase layer is mandatory: every business operation lives here, and
2
+ // handlers call nothing below it. Dependencies are consumer-owned interfaces —
3
+ // the usecase says what it needs, adapters satisfy it.
4
+ import { createNote, makeNoteCreatedEvent, type Note, type NoteCreatedEvent } from '@app/core';
5
+ import type { Logger } from '@app/shared';
6
+
7
+ export interface NoteStore {
8
+ put(note: Note): Promise<void>;
9
+ }
10
+
11
+ export interface EventPublisher {
12
+ publish(event: NoteCreatedEvent): Promise<void>;
13
+ }
14
+
15
+ export interface CreateNoteDeps {
16
+ notes: NoteStore;
17
+ events: EventPublisher;
18
+ /** Id generation and the clock are decided here, never inside the core. */
19
+ newId(): string;
20
+ now(): string;
21
+ log: Logger;
22
+ }
23
+
24
+ export async function createNoteUsecase(input: unknown, deps: CreateNoteDeps): Promise<Note> {
25
+ const note = createNote(input, { id: deps.newId(), createdAt: deps.now() });
26
+ await deps.notes.put(note);
27
+ await deps.events.publish(makeNoteCreatedEvent(note));
28
+ deps.log.info('note created', { noteId: note.id, slug: note.slug });
29
+ return note;
30
+ }
@@ -0,0 +1,14 @@
1
+ // Even a trivial read goes through a usecase — the uniformity is the rule.
2
+ import type { Note } from '@app/core';
3
+
4
+ export interface NoteLister {
5
+ list(): Promise<Note[]>;
6
+ }
7
+
8
+ export interface ListNotesDeps {
9
+ notes: NoteLister;
10
+ }
11
+
12
+ export async function listNotesUsecase(deps: ListNotesDeps): Promise<Note[]> {
13
+ return deps.notes.list();
14
+ }
@@ -0,0 +1,64 @@
1
+ import { AppError, createLogger } from '@app/shared';
2
+ import { describe, expect, it } from 'vitest';
3
+ import { makeCreateNoteHandler } from '../src/handlers/create-note.js';
4
+ import type { CreateNoteDeps } from '../src/usecases/create-note.js';
5
+
6
+ function stubDeps(overrides: Partial<CreateNoteDeps> = {}) {
7
+ const deps: CreateNoteDeps = {
8
+ notes: { put: async () => {} },
9
+ events: { publish: async () => {} },
10
+ newId: () => 'fixed-id',
11
+ now: () => '2024-01-01T00:00:00.000Z',
12
+ log: createLogger({}, () => {}),
13
+ ...overrides,
14
+ };
15
+ return deps;
16
+ }
17
+
18
+ describe('POST /notes handler', () => {
19
+ it('returns 201 with the created note', async () => {
20
+ const handler = makeCreateNoteHandler(stubDeps());
21
+ const result = await handler(JSON.stringify({ title: 'Hello' }));
22
+ expect(result.statusCode).toBe(201);
23
+ expect(JSON.parse(result.body)).toMatchObject({
24
+ note: { id: 'fixed-id', slug: 'hello', createdAt: '2024-01-01T00:00:00.000Z' },
25
+ });
26
+ });
27
+
28
+ it('returns 400 for malformed JSON', async () => {
29
+ const result = await makeCreateNoteHandler(stubDeps())('{not json');
30
+ expect(result.statusCode).toBe(400);
31
+ });
32
+
33
+ it('returns 400 with issues for invalid input', async () => {
34
+ const result = await makeCreateNoteHandler(stubDeps())(JSON.stringify({ title: '' }));
35
+ expect(result.statusCode).toBe(400);
36
+ expect(JSON.parse(result.body).issues.join(' ')).toMatch(/title/);
37
+ });
38
+
39
+ it('maps typed errors to their status code', async () => {
40
+ const handler = makeCreateNoteHandler(
41
+ stubDeps({
42
+ notes: {
43
+ put: () => Promise.reject(new AppError('conflict', { statusCode: 409, code: 'DUP' })),
44
+ },
45
+ }),
46
+ );
47
+ const result = await handler(JSON.stringify({ title: 'T' }));
48
+ expect(result.statusCode).toBe(409);
49
+ });
50
+
51
+ it('hides unknown errors behind a 500 and logs them', async () => {
52
+ const logs: string[] = [];
53
+ const handler = makeCreateNoteHandler(
54
+ stubDeps({
55
+ notes: { put: () => Promise.reject(new Error('secret detail')) },
56
+ log: createLogger({}, (line) => logs.push(line)),
57
+ }),
58
+ );
59
+ const result = await handler(JSON.stringify({ title: 'T' }));
60
+ expect(result.statusCode).toBe(500);
61
+ expect(result.body).not.toContain('secret detail');
62
+ expect(logs.join('\n')).toContain('secret detail');
63
+ });
64
+ });
@@ -0,0 +1,43 @@
1
+ import { InvalidNoteError, type Note, type NoteCreatedEvent } from '@app/core';
2
+ import { createLogger } from '@app/shared';
3
+ import { describe, expect, it } from 'vitest';
4
+ import { createNoteUsecase, type CreateNoteDeps } from '../src/usecases/create-note.js';
5
+
6
+ function stubDeps() {
7
+ const stored: Note[] = [];
8
+ const published: NoteCreatedEvent[] = [];
9
+ const logs: string[] = [];
10
+ const deps: CreateNoteDeps = {
11
+ notes: { put: async (note) => void stored.push(note) },
12
+ events: { publish: async (event) => void published.push(event) },
13
+ newId: () => 'fixed-id',
14
+ now: () => '2024-01-01T00:00:00.000Z',
15
+ log: createLogger({}, (line) => logs.push(line)),
16
+ };
17
+ return { deps, stored, published, logs };
18
+ }
19
+
20
+ describe('createNoteUsecase', () => {
21
+ it('creates, stores, publishes, and logs', async () => {
22
+ const { deps, stored, published, logs } = stubDeps();
23
+ const note = await createNoteUsecase({ title: 'Hello World' }, deps);
24
+ expect(note).toMatchObject({ id: 'fixed-id', slug: 'hello-world' });
25
+ expect(stored).toEqual([note]);
26
+ expect(published).toEqual([{ type: 'note.created', note }]);
27
+ expect(logs.join('\n')).toContain('fixed-id');
28
+ });
29
+
30
+ it('rejects invalid input before any side effect', async () => {
31
+ const { deps, stored, published } = stubDeps();
32
+ await expect(createNoteUsecase({ title: '' }, deps)).rejects.toThrow(InvalidNoteError);
33
+ expect(stored).toEqual([]);
34
+ expect(published).toEqual([]);
35
+ });
36
+
37
+ it('propagates store failures and does not publish', async () => {
38
+ const { deps, published } = stubDeps();
39
+ deps.notes = { put: () => Promise.reject(new Error('disk full')) };
40
+ await expect(createNoteUsecase({ title: 'T' }, deps)).rejects.toThrow('disk full');
41
+ expect(published).toEqual([]);
42
+ });
43
+ });
@@ -0,0 +1,48 @@
1
+ import { AppError, createLogger } from '@app/shared';
2
+ import { describe, expect, it } from 'vitest';
3
+ import { makeListNotesHandler } from '../src/handlers/list-notes.js';
4
+ import { listNotesUsecase } from '../src/usecases/list-notes.js';
5
+
6
+ const note = {
7
+ id: 'n1',
8
+ title: 'Hello',
9
+ slug: 'hello',
10
+ tags: [],
11
+ createdAt: '2024-01-01T00:00:00.000Z',
12
+ };
13
+
14
+ describe('listNotesUsecase', () => {
15
+ it('returns what the store lists', async () => {
16
+ expect(await listNotesUsecase({ notes: { list: async () => [note] } })).toEqual([note]);
17
+ });
18
+ });
19
+
20
+ describe('GET /notes handler', () => {
21
+ it('returns 200 with the notes', async () => {
22
+ const handler = makeListNotesHandler({
23
+ notes: { list: async () => [note] },
24
+ log: createLogger({}, () => {}),
25
+ });
26
+ const result = await handler();
27
+ expect(result.statusCode).toBe(200);
28
+ expect(JSON.parse(result.body)).toEqual({ notes: [note] });
29
+ });
30
+
31
+ it('maps typed errors and hides unknown ones', async () => {
32
+ const typed = makeListNotesHandler({
33
+ notes: { list: () => Promise.reject(new AppError('nope', { statusCode: 503, code: 'X' })) },
34
+ log: createLogger({}, () => {}),
35
+ });
36
+ expect((await typed()).statusCode).toBe(503);
37
+
38
+ const logs: string[] = [];
39
+ const unknown = makeListNotesHandler({
40
+ notes: { list: () => Promise.reject(new Error('secret')) },
41
+ log: createLogger({}, (line) => logs.push(line)),
42
+ });
43
+ const result = await unknown();
44
+ expect(result.statusCode).toBe(500);
45
+ expect(result.body).not.toContain('secret');
46
+ expect(logs.join('')).toContain('secret');
47
+ });
48
+ });
@@ -0,0 +1,123 @@
1
+ // Integration: the real HTTP server over a real socket, with real file
2
+ // storage and a real spool directory — the whole request path at once.
3
+ import { mkdir, mkdtemp, readdir, rm, writeFile } from 'node:fs/promises';
4
+ import { tmpdir } from 'node:os';
5
+ import type { AddressInfo } from 'node:net';
6
+ import path from 'node:path';
7
+ import { JsonFileNoteStore } from '@app/db';
8
+ import { createLogger } from '@app/shared';
9
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
10
+ import type { Server } from 'node:http';
11
+ import { SpoolEventPublisher } from '../src/adapters/spool-publisher.js';
12
+ import { makeServer } from '../src/server.js';
13
+
14
+ let dir: string;
15
+ let server: Server;
16
+ let baseUrl: string;
17
+
18
+ beforeEach(async () => {
19
+ dir = await mkdtemp(path.join(tmpdir(), 'api-int-'));
20
+ let n = 0;
21
+ server = makeServer({
22
+ notes: new JsonFileNoteStore(path.join(dir, 'data', 'notes.json')),
23
+ events: new SpoolEventPublisher(path.join(dir, 'queue'), () => `msg-${++n}`),
24
+ newId: () => `id-${++n}`,
25
+ now: () => '2024-01-01T00:00:00.000Z',
26
+ log: createLogger({}, () => {}),
27
+ });
28
+ await new Promise<void>((resolve) => server.listen(0, resolve));
29
+ baseUrl = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
30
+ });
31
+
32
+ afterEach(async () => {
33
+ await new Promise((resolve) => server.close(resolve));
34
+ await rm(dir, { recursive: true, force: true });
35
+ });
36
+
37
+ describe('the service end to end', () => {
38
+ it('creates a note over HTTP, persists it, and spools the event', async () => {
39
+ const response = await fetch(`${baseUrl}/notes`, {
40
+ method: 'POST',
41
+ headers: { 'content-type': 'application/json' },
42
+ body: JSON.stringify({ title: 'Integration proof', tags: ['e2e'] }),
43
+ });
44
+ expect(response.status).toBe(201);
45
+ const { note } = (await response.json()) as { note: { slug: string } };
46
+ expect(note.slug).toBe('integration-proof');
47
+
48
+ expect(await readdir(path.join(dir, 'queue'))).toHaveLength(1);
49
+ expect(await readdir(path.join(dir, 'data'))).toContain('notes.json');
50
+ });
51
+
52
+ it('rejects invalid input with 400', async () => {
53
+ const response = await fetch(`${baseUrl}/notes`, {
54
+ method: 'POST',
55
+ body: JSON.stringify({ title: '' }),
56
+ });
57
+ expect(response.status).toBe(400);
58
+ });
59
+
60
+ it('404s unknown routes', async () => {
61
+ const response = await fetch(`${baseUrl}/other`);
62
+ expect(response.status).toBe(404);
63
+ });
64
+
65
+ it('lists created notes back on GET /notes (the UI read path)', async () => {
66
+ for (const title of ['First note', 'Second note']) {
67
+ await fetch(`${baseUrl}/notes`, {
68
+ method: 'POST',
69
+ headers: { 'content-type': 'application/json' },
70
+ body: JSON.stringify({ title }),
71
+ });
72
+ }
73
+ const response = await fetch(`${baseUrl}/notes`);
74
+ expect(response.status).toBe(200);
75
+ const { notes } = (await response.json()) as { notes: Array<{ title: string }> };
76
+ expect(notes).toHaveLength(2);
77
+ });
78
+ });
79
+
80
+ describe('static serving (the built web bundle)', () => {
81
+ let staticServer: Server;
82
+ let staticBase: string;
83
+
84
+ beforeEach(async () => {
85
+ await mkdir(path.join(dir, 'static'), { recursive: true });
86
+ await writeFile(path.join(dir, 'static', 'index.html'), '<h1>web shell</h1>');
87
+ staticServer = makeServer(
88
+ {
89
+ notes: new JsonFileNoteStore(path.join(dir, 'data', 'notes.json')),
90
+ events: new SpoolEventPublisher(path.join(dir, 'queue'), () => 'm'),
91
+ newId: () => 'id',
92
+ now: () => '2024-01-01T00:00:00.000Z',
93
+ log: createLogger({}, () => {}),
94
+ },
95
+ { staticDir: path.join(dir, 'static') },
96
+ );
97
+ await new Promise<void>((resolve) => staticServer.listen(0, resolve));
98
+ staticBase = `http://127.0.0.1:${(staticServer.address() as AddressInfo).port}`;
99
+ });
100
+
101
+ afterEach(async () => {
102
+ await new Promise((resolve) => staticServer.close(resolve));
103
+ });
104
+
105
+ it('serves index.html at the root', async () => {
106
+ const response = await fetch(staticBase + '/');
107
+ expect(response.status).toBe(200);
108
+ expect(response.headers.get('content-type')).toContain('text/html');
109
+ expect(await response.text()).toContain('web shell');
110
+ });
111
+
112
+ it('refuses path traversal out of the static dir', async () => {
113
+ await writeFile(path.join(dir, 'secret.txt'), 'nope');
114
+ const response = await fetch(staticBase + '/%2e%2e/secret.txt');
115
+ expect(response.status).toBe(404);
116
+ });
117
+
118
+ it('API routes still win over static files', async () => {
119
+ const response = await fetch(staticBase + '/notes');
120
+ expect(response.status).toBe(200);
121
+ expect(response.headers.get('content-type')).toContain('application/json');
122
+ });
123
+ });
@@ -0,0 +1,32 @@
1
+ import { mkdtemp, readFile, readdir, rm } from 'node:fs/promises';
2
+ import { tmpdir } from 'node:os';
3
+ import path from 'node:path';
4
+ import { createNote, makeNoteCreatedEvent } from '@app/core';
5
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
6
+ import { SpoolEventPublisher } from '../src/adapters/spool-publisher.js';
7
+
8
+ let dir: string;
9
+
10
+ beforeEach(async () => {
11
+ dir = await mkdtemp(path.join(tmpdir(), 'spool-'));
12
+ });
13
+
14
+ afterEach(async () => {
15
+ await rm(dir, { recursive: true, force: true });
16
+ });
17
+
18
+ describe('SpoolEventPublisher', () => {
19
+ it('writes one complete JSON message file per event', async () => {
20
+ const queueDir = path.join(dir, 'queue');
21
+ let n = 0;
22
+ const publisher = new SpoolEventPublisher(queueDir, () => `msg-${++n}`);
23
+ const note = createNote({ title: 'Hi' }, { id: 'n1', createdAt: '2024-01-01T00:00:00.000Z' });
24
+ const event = makeNoteCreatedEvent(note);
25
+ await publisher.publish(event);
26
+ await publisher.publish(event);
27
+
28
+ const files = (await readdir(queueDir)).sort();
29
+ expect(files).toEqual(['msg-1.json', 'msg-2.json']);
30
+ expect(JSON.parse(await readFile(path.join(queueDir, 'msg-1.json'), 'utf8'))).toEqual(event);
31
+ });
32
+ });
@@ -0,0 +1,16 @@
1
+ {
2
+ "name": "@app/worker",
3
+ "version": "0.1.0",
4
+ "private": true,
5
+ "type": "module",
6
+ "main": "src/main.ts",
7
+ "types": "src/main.ts",
8
+ "scripts": {
9
+ "start": "tsx src/main.ts"
10
+ },
11
+ "dependencies": {
12
+ "@app/core": "workspace:*",
13
+ "@app/shared": "workspace:*",
14
+ "zod": "^4.4.3"
15
+ }
16
+ }
@@ -0,0 +1,28 @@
1
+ // Composition root — the worker as a long-running process polling the spool.
2
+ import { z } from 'zod';
3
+ import { createLogger, loadEnv } from '@app/shared';
4
+ import { processNoteCreated } from './usecases/process-note-created.js';
5
+ import { processSpoolOnce } from './spool.js';
6
+
7
+ const env = loadEnv(
8
+ z.object({
9
+ QUEUE_DIR: z.string().min(1).default('var/queue'),
10
+ DLQ_DIR: z.string().min(1).default('var/dlq'),
11
+ POLL_INTERVAL_MS: z.coerce.number().int().positive().default(1000),
12
+ }),
13
+ process.env,
14
+ );
15
+
16
+ const log = createLogger({ service: 'worker' });
17
+ const config = { queueDir: env.QUEUE_DIR, dlqDir: env.DLQ_DIR, maxAttempts: 3 };
18
+ const deps = { log, process: (raw: string) => processNoteCreated(raw, { log }) };
19
+
20
+ log.info('worker polling', { queueDir: env.QUEUE_DIR, intervalMs: env.POLL_INTERVAL_MS });
21
+
22
+ const tick = async () => {
23
+ const processed = await processSpoolOnce(config, deps);
24
+ if (processed > 0) log.info('spool pass complete', { processed });
25
+ setTimeout(() => void tick(), env.POLL_INTERVAL_MS);
26
+ };
27
+
28
+ void tick();