create-agent-rig 0.9.1 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +419 -3
- package/README.md +267 -305
- package/package.json +8 -14
- package/packages/cli/dist/commands/create.js +56 -88
- package/packages/cli/dist/commands/doctor.js +213 -0
- package/packages/cli/dist/commands/init.js +111 -50
- package/packages/cli/dist/commands/integrations.js +468 -0
- package/packages/cli/dist/commands/setup-wizard.js +61 -0
- package/packages/cli/dist/commands/uninstall.js +1223 -0
- package/packages/cli/dist/commands/upgrade.js +472 -90
- package/packages/cli/dist/index.js +651 -46
- package/packages/cli/dist/integrations/declaration.js +158 -0
- package/packages/cli/dist/integrations/doctor-guards.js +126 -0
- package/packages/cli/dist/integrations/doctor-workflow.js +25 -0
- package/packages/cli/dist/integrations/mcp-json.js +107 -0
- package/packages/cli/dist/integrations/memory-doctor.js +124 -0
- package/packages/cli/dist/integrations/registry.js +22 -0
- package/packages/cli/dist/integrations/spawn.js +228 -0
- package/packages/cli/dist/integrations/spec-kit.js +280 -0
- package/packages/cli/dist/integrations/verify.js +193 -0
- package/packages/cli/dist/integrations/windows-job.js +275 -0
- package/packages/cli/dist/lib/elevated-paths.js +74 -0
- package/packages/cli/dist/lib/install-set.js +1 -44
- package/packages/cli/dist/lib/manifest.js +64 -5
- package/packages/cli/dist/lib/prompts.js +0 -23
- package/packages/cli/dist/lib/safe-path.js +193 -2
- package/packages/cli/dist/lib/safe-text.js +78 -0
- package/packages/cli/dist/lib/substitute.js +1 -52
- package/packages/cli/dist/lib/summary.js +5 -5
- package/packages/cli/dist/templates.js +4 -14
- package/scripts/prepare.mjs +28 -12
- package/templates/agent-os/subagent-routing.json +4 -4
- package/templates/agent-os/universal/.agents/skills/check-premises/SKILL.md +13 -9
- package/templates/agent-os/universal/.agents/skills/loop/SKILL.md +31 -6
- package/templates/agent-os/universal/.agents/skills/new-invariant/SKILL.md +5 -5
- package/templates/agent-os/universal/.agents/skills/pr-ship/SKILL.md +1 -1
- package/templates/agent-os/universal/.agents/skills/worktree-task/SKILL.md +3 -3
- package/templates/agent-os/universal/.claude/agents/code-reviewer.md +8 -6
- package/templates/agent-os/universal/.claude/agents/implementation-agent.md +41 -0
- package/templates/agent-os/universal/.claude/agents/prose-reviewer.md +8 -4
- package/templates/agent-os/universal/.claude/agents/security-scanner.md +4 -2
- package/templates/agent-os/universal/.claude/agents/test-writer.md +2 -2
- package/templates/agent-os/universal/.claude/hooks/inject-rules.mjs +83 -4
- package/templates/agent-os/universal/.claude/rules/autonomy.md +55 -27
- package/templates/agent-os/universal/.claude/rules/invariants.md +9 -8
- package/templates/agent-os/universal/.claude/rules/workflow.md +47 -22
- package/templates/agent-os/universal/.claude/scripts/decision-router.mjs +1 -1
- package/templates/agent-os/universal/.claude/scripts/detect-missed-gate.mjs +12 -9
- package/templates/agent-os/universal/.claude/scripts/doctor.mjs +100 -2
- package/templates/agent-os/universal/.claude/scripts/lib/claim-records.mjs +236 -6
- package/templates/agent-os/universal/.claude/scripts/queue/state.mjs +2 -2
- package/templates/agent-os/universal/.claude/scripts/reconcile-external-prs.mjs +4 -3
- package/templates/agent-os/universal/.claude/scripts/revalidate.mjs +16 -0
- package/templates/agent-os/universal/.claude/settings.json +0 -8
- package/templates/agent-os/universal/.claude/skills/check-premises/SKILL.md +13 -9
- package/templates/agent-os/universal/.claude/skills/loop/SKILL.md +31 -6
- package/templates/agent-os/universal/.claude/skills/new-invariant/SKILL.md +5 -5
- package/templates/agent-os/universal/.claude/skills/pr-ship/SKILL.md +1 -1
- package/templates/agent-os/universal/.claude/skills/worktree-task/SKILL.md +3 -3
- package/templates/agent-os/universal/.codex/agents/code-reviewer.toml +2 -2
- package/templates/agent-os/universal/.codex/agents/implementation-agent.toml +6 -0
- package/templates/agent-os/universal/.codex/agents/prose-reviewer.toml +2 -2
- package/templates/agent-os/universal/.codex/agents/security-scanner.toml +1 -1
- package/templates/agent-os/universal/.codex/agents/test-writer.toml +1 -1
- package/templates/agent-os/universal/.codex/hooks.json +0 -10
- package/templates/agent-os/universal/AGENTS.md +208 -107
- package/templates/agent-os/universal/CLAUDE.md +15 -162
- package/templates/agent-os/universal/PLAN.md +17 -10
- package/templates/agent-os/universal/docs/decisions/agents-md-canonical.md +313 -0
- package/templates/agent-os/universal/docs/decisions/codex-adapter.md +15 -10
- package/templates/agent-os/universal/docs/decisions/review-lanes.md +12 -8
- package/templates/agent-os/universal/docs/decisions/session-start-wire-format.md +206 -0
- package/templates/agent-os/universal/docs/decisions/subagent-routing.md +6 -3
- package/templates/agent-os/universal/docs/decisions/workflow-layer-split.md +235 -0
- package/templates/agent-os/universal/layers.json +25 -30
- package/templates/hash-history.json +8 -4
- package/templates/release-ledger.json +2 -1
- package/packages/cli/dist/lib/composition.js +0 -20
- package/packages/cli/dist/lib/targets.js +0 -28
- package/packages/cli/dist/policy/benchmark/corpus.js +0 -165
- package/packages/cli/dist/policy/core/adapter.js +0 -18
- package/packages/cli/dist/policy/core/coverage.js +0 -253
- package/packages/cli/dist/policy/core/decision-record.js +0 -287
- package/packages/cli/dist/policy/core/declaration.js +0 -127
- package/packages/cli/dist/policy/core/evidence-matrix.js +0 -94
- package/packages/cli/dist/policy/core/probe.js +0 -442
- package/packages/cli/dist/policy/core/registry.js +0 -115
- package/packages/cli/dist/policy/core/validation.js +0 -275
- package/packages/cli/dist/policy/core/vocabulary.js +0 -123
- package/packages/cli/dist/policy/harness/claude.js +0 -47
- package/packages/cli/dist/policy/harness/codex.js +0 -87
- package/packages/cli/dist/policy/harness/index.js +0 -15
- package/packages/cli/dist/policy/harness/shared-hooks.js +0 -28
- package/packages/cli/dist/policy/index.js +0 -17
- package/templates/agent-os/init/AGENTS.md +0 -201
- package/templates/agent-os/init/CLAUDE.md +0 -201
- package/templates/agent-os/stack/aws-cdk/.agents/skills/post-deploy-verify/SKILL.md +0 -105
- package/templates/agent-os/stack/aws-cdk/.agents/skills/ro-debug/SKILL.md +0 -117
- package/templates/agent-os/stack/aws-cdk/.claude/agents/cdk-diff-reviewer.md +0 -89
- package/templates/agent-os/stack/aws-cdk/.claude/rules/aws-cdk.md +0 -105
- package/templates/agent-os/stack/aws-cdk/.claude/skills/post-deploy-verify/SKILL.md +0 -105
- package/templates/agent-os/stack/aws-cdk/.claude/skills/ro-debug/SKILL.md +0 -117
- package/templates/agent-os/stack/aws-cdk/.codex/agents/cdk-diff-reviewer.toml +0 -6
- package/templates/agent-os/stack/node-ts/.claude/hooks/dod-checks.json +0 -1
- package/templates/agent-os/stack/node-ts/.claude/rules/node-ts.md +0 -85
- package/templates/agent-os/universal/.claude/hooks/guard-core-purity.mjs +0 -89
- package/templates/agent-os/universal/.claude/hooks/guard-web-boundary.mjs +0 -68
- package/templates/agent-os/universal/.claude/rules/architecture.md +0 -81
- package/templates/skeleton/aws-serverless/.github/workflows/ci.yml +0 -28
- package/templates/skeleton/aws-serverless/.github/workflows/deploy.yml +0 -90
- package/templates/skeleton/aws-serverless/README.md +0 -181
- package/templates/skeleton/aws-serverless/apps/web/next.config.mjs +0 -17
- package/templates/skeleton/aws-serverless/apps/web/package.json +0 -19
- package/templates/skeleton/aws-serverless/apps/web/src/app/layout.tsx +0 -17
- package/templates/skeleton/aws-serverless/apps/web/src/app/page.tsx +0 -96
- package/templates/skeleton/aws-serverless/apps/web/src/lib/api.ts +0 -36
- package/templates/skeleton/aws-serverless/apps/web/src/lib/validate.ts +0 -23
- package/templates/skeleton/aws-serverless/apps/web/test/shared-validation.test.ts +0 -38
- package/templates/skeleton/aws-serverless/apps/web/tsconfig.json +0 -14
- package/templates/skeleton/aws-serverless/eslint.config.mjs +0 -20
- package/templates/skeleton/aws-serverless/gitignore +0 -56
- package/templates/skeleton/aws-serverless/infra/bin/app.ts +0 -79
- package/templates/skeleton/aws-serverless/infra/cdk.json +0 -3
- package/templates/skeleton/aws-serverless/infra/lib/app-stack.ts +0 -248
- package/templates/skeleton/aws-serverless/infra/lib/web-stack.ts +0 -45
- package/templates/skeleton/aws-serverless/infra/package.json +0 -18
- package/templates/skeleton/aws-serverless/infra/test/allowed-origins.test.ts +0 -301
- package/templates/skeleton/aws-serverless/infra/test/app-composition.test.ts +0 -137
- package/templates/skeleton/aws-serverless/infra/test/app-stack.test.ts +0 -120
- package/templates/skeleton/aws-serverless/infra/test/web-stack.test.ts +0 -44
- package/templates/skeleton/aws-serverless/package.json +0 -30
- package/templates/skeleton/aws-serverless/packages/core/package.json +0 -11
- package/templates/skeleton/aws-serverless/packages/core/src/events.ts +0 -14
- package/templates/skeleton/aws-serverless/packages/core/src/index.ts +0 -15
- package/templates/skeleton/aws-serverless/packages/core/src/note.ts +0 -69
- package/templates/skeleton/aws-serverless/packages/core/test/events.test.ts +0 -23
- package/templates/skeleton/aws-serverless/packages/core/test/note.test.ts +0 -101
- package/templates/skeleton/aws-serverless/packages/db/package.json +0 -14
- package/templates/skeleton/aws-serverless/packages/db/src/client.ts +0 -17
- package/templates/skeleton/aws-serverless/packages/db/src/index.ts +0 -2
- package/templates/skeleton/aws-serverless/packages/db/src/note-model.ts +0 -52
- package/templates/skeleton/aws-serverless/packages/db/test/note-model.test.ts +0 -91
- package/templates/skeleton/aws-serverless/packages/shared/package.json +0 -11
- package/templates/skeleton/aws-serverless/packages/shared/src/env.ts +0 -17
- package/templates/skeleton/aws-serverless/packages/shared/src/errors.ts +0 -33
- package/templates/skeleton/aws-serverless/packages/shared/src/index.ts +0 -3
- package/templates/skeleton/aws-serverless/packages/shared/src/logger.ts +0 -20
- package/templates/skeleton/aws-serverless/packages/shared/test/env.test.ts +0 -26
- package/templates/skeleton/aws-serverless/packages/shared/test/errors.test.ts +0 -28
- package/templates/skeleton/aws-serverless/packages/shared/test/logger.test.ts +0 -19
- package/templates/skeleton/aws-serverless/pnpm-lock.yaml +0 -2855
- package/templates/skeleton/aws-serverless/pnpm-workspace.yaml +0 -14
- package/templates/skeleton/aws-serverless/services/api/package.json +0 -15
- package/templates/skeleton/aws-serverless/services/api/src/adapters/sqs-publisher.ts +0 -26
- package/templates/skeleton/aws-serverless/services/api/src/handlers/create-note.ts +0 -69
- package/templates/skeleton/aws-serverless/services/api/src/handlers/list-notes.ts +0 -37
- package/templates/skeleton/aws-serverless/services/api/src/list-main.ts +0 -12
- package/templates/skeleton/aws-serverless/services/api/src/main.ts +0 -21
- package/templates/skeleton/aws-serverless/services/api/src/usecases/create-note.ts +0 -41
- package/templates/skeleton/aws-serverless/services/api/src/usecases/list-notes.ts +0 -14
- package/templates/skeleton/aws-serverless/services/api/test/create-note.handler.test.ts +0 -211
- package/templates/skeleton/aws-serverless/services/api/test/create-note.usecase.test.ts +0 -45
- package/templates/skeleton/aws-serverless/services/api/test/list-notes.test.ts +0 -122
- package/templates/skeleton/aws-serverless/services/api/test/sqs-publisher.test.ts +0 -22
- package/templates/skeleton/aws-serverless/services/worker/package.json +0 -12
- package/templates/skeleton/aws-serverless/services/worker/src/handlers/note-created.ts +0 -15
- package/templates/skeleton/aws-serverless/services/worker/src/main.ts +0 -7
- package/templates/skeleton/aws-serverless/services/worker/src/usecases/process-note-created.ts +0 -37
- package/templates/skeleton/aws-serverless/services/worker/test/note-created.test.ts +0 -61
- package/templates/skeleton/aws-serverless/tsconfig.base.json +0 -15
- package/templates/skeleton/aws-serverless/tsconfig.json +0 -16
- package/templates/skeleton/aws-serverless/vitest.config.ts +0 -14
- package/templates/skeleton/node-service/.github/workflows/ci.yml +0 -27
- package/templates/skeleton/node-service/.github/workflows/deploy.yml +0 -29
- package/templates/skeleton/node-service/README.md +0 -104
- package/templates/skeleton/node-service/apps/web/next.config.mjs +0 -17
- package/templates/skeleton/node-service/apps/web/package.json +0 -19
- package/templates/skeleton/node-service/apps/web/src/app/layout.tsx +0 -17
- package/templates/skeleton/node-service/apps/web/src/app/page.tsx +0 -96
- package/templates/skeleton/node-service/apps/web/src/lib/api.ts +0 -29
- package/templates/skeleton/node-service/apps/web/src/lib/validate.ts +0 -23
- package/templates/skeleton/node-service/apps/web/test/shared-validation.test.ts +0 -38
- package/templates/skeleton/node-service/apps/web/tsconfig.json +0 -14
- package/templates/skeleton/node-service/eslint.config.mjs +0 -20
- package/templates/skeleton/node-service/gitignore +0 -54
- package/templates/skeleton/node-service/package.json +0 -30
- package/templates/skeleton/node-service/packages/core/package.json +0 -11
- package/templates/skeleton/node-service/packages/core/src/events.ts +0 -14
- package/templates/skeleton/node-service/packages/core/src/index.ts +0 -15
- package/templates/skeleton/node-service/packages/core/src/note.ts +0 -69
- package/templates/skeleton/node-service/packages/core/test/events.test.ts +0 -23
- package/templates/skeleton/node-service/packages/core/test/note.test.ts +0 -101
- package/templates/skeleton/node-service/packages/db/package.json +0 -12
- package/templates/skeleton/node-service/packages/db/src/index.ts +0 -1
- package/templates/skeleton/node-service/packages/db/src/note-store.ts +0 -100
- package/templates/skeleton/node-service/packages/db/test/note-store.test.ts +0 -100
- package/templates/skeleton/node-service/packages/shared/package.json +0 -11
- package/templates/skeleton/node-service/packages/shared/src/env.ts +0 -17
- package/templates/skeleton/node-service/packages/shared/src/errors.ts +0 -33
- package/templates/skeleton/node-service/packages/shared/src/index.ts +0 -3
- package/templates/skeleton/node-service/packages/shared/src/logger.ts +0 -20
- package/templates/skeleton/node-service/packages/shared/test/env.test.ts +0 -26
- package/templates/skeleton/node-service/packages/shared/test/errors.test.ts +0 -28
- package/templates/skeleton/node-service/packages/shared/test/logger.test.ts +0 -19
- package/templates/skeleton/node-service/pnpm-lock.yaml +0 -2402
- package/templates/skeleton/node-service/pnpm-workspace.yaml +0 -13
- package/templates/skeleton/node-service/scripts/build-artifact.mjs +0 -34
- package/templates/skeleton/node-service/services/api/package.json +0 -17
- package/templates/skeleton/node-service/services/api/src/adapters/spool-publisher.ts +0 -23
- package/templates/skeleton/node-service/services/api/src/handlers/create-note.ts +0 -40
- package/templates/skeleton/node-service/services/api/src/handlers/list-notes.ts +0 -23
- package/templates/skeleton/node-service/services/api/src/main.ts +0 -40
- package/templates/skeleton/node-service/services/api/src/server.ts +0 -172
- package/templates/skeleton/node-service/services/api/src/static-dir.ts +0 -20
- package/templates/skeleton/node-service/services/api/src/usecases/create-note.ts +0 -30
- package/templates/skeleton/node-service/services/api/src/usecases/list-notes.ts +0 -14
- package/templates/skeleton/node-service/services/api/test/artifact.test.ts +0 -92
- package/templates/skeleton/node-service/services/api/test/create-note.handler.test.ts +0 -64
- package/templates/skeleton/node-service/services/api/test/create-note.usecase.test.ts +0 -43
- package/templates/skeleton/node-service/services/api/test/list-notes.test.ts +0 -48
- package/templates/skeleton/node-service/services/api/test/package-manager.test.ts +0 -40
- package/templates/skeleton/node-service/services/api/test/package-manager.ts +0 -51
- package/templates/skeleton/node-service/services/api/test/server.test.ts +0 -208
- package/templates/skeleton/node-service/services/api/test/spool-publisher.test.ts +0 -32
- package/templates/skeleton/node-service/services/api/test/static-dir.test.ts +0 -29
- package/templates/skeleton/node-service/services/worker/package.json +0 -16
- package/templates/skeleton/node-service/services/worker/src/main.ts +0 -28
- package/templates/skeleton/node-service/services/worker/src/spool.ts +0 -60
- package/templates/skeleton/node-service/services/worker/src/usecases/process-note-created.ts +0 -38
- package/templates/skeleton/node-service/services/worker/test/process-note-created.test.ts +0 -34
- package/templates/skeleton/node-service/services/worker/test/spool.test.ts +0 -76
- package/templates/skeleton/node-service/tsconfig.base.json +0 -15
- package/templates/skeleton/node-service/tsconfig.json +0 -13
- package/templates/skeleton/node-service/vitest.config.ts +0 -12
|
@@ -1,48 +0,0 @@
|
|
|
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
|
-
});
|
|
@@ -1,40 +0,0 @@
|
|
|
1
|
-
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
|
|
2
|
-
import { tmpdir } from 'node:os';
|
|
3
|
-
import path from 'node:path';
|
|
4
|
-
import { afterEach, describe, expect, it } from 'vitest';
|
|
5
|
-
|
|
6
|
-
import { runPackageManager } from './package-manager.js';
|
|
7
|
-
|
|
8
|
-
const literalArgs = ['space value', '&|<>^%!()', 'single "double"', ''];
|
|
9
|
-
|
|
10
|
-
describe('package-manager transport for the artifact fallback', () => {
|
|
11
|
-
let work: string | undefined;
|
|
12
|
-
|
|
13
|
-
afterEach(async () => {
|
|
14
|
-
if (work !== undefined) await rm(work, { recursive: true, force: true });
|
|
15
|
-
});
|
|
16
|
-
|
|
17
|
-
it('honors the trusted current pnpm CLI on every platform with literal argv and preserves a failing child output', async () => {
|
|
18
|
-
work = await mkdtemp(path.join(tmpdir(), 'artifact-package-manager-'));
|
|
19
|
-
const cli = path.join(work, 'pnpm-cli.cjs');
|
|
20
|
-
await writeFile(
|
|
21
|
-
cli,
|
|
22
|
-
[
|
|
23
|
-
'process.stdout.write(JSON.stringify(process.argv.slice(2)));',
|
|
24
|
-
"process.stderr.write('artifact transport stderr');",
|
|
25
|
-
'process.exit(7);',
|
|
26
|
-
].join(''),
|
|
27
|
-
);
|
|
28
|
-
|
|
29
|
-
const failure = await runPackageManager(literalArgs, {
|
|
30
|
-
cwd: work,
|
|
31
|
-
env: { ...process.env, npm_execpath: cli },
|
|
32
|
-
}).catch((error: unknown) => error);
|
|
33
|
-
|
|
34
|
-
expect(failure).toMatchObject({
|
|
35
|
-
code: 7,
|
|
36
|
-
stdout: JSON.stringify(literalArgs),
|
|
37
|
-
stderr: 'artifact transport stderr',
|
|
38
|
-
});
|
|
39
|
-
});
|
|
40
|
-
});
|
|
@@ -1,51 +0,0 @@
|
|
|
1
|
-
import { execFile } from 'node:child_process';
|
|
2
|
-
import { existsSync } from 'node:fs';
|
|
3
|
-
import path from 'node:path';
|
|
4
|
-
import { promisify } from 'node:util';
|
|
5
|
-
|
|
6
|
-
const exec = promisify(execFile);
|
|
7
|
-
|
|
8
|
-
const currentPnpmCli = (environment: NodeJS.ProcessEnv): string | undefined => {
|
|
9
|
-
const currentCli = environment.npm_execpath;
|
|
10
|
-
if (currentCli && /^pnpm(?:-cli)?\.(?:cjs|js)$/i.test(path.basename(currentCli)) && existsSync(currentCli)) {
|
|
11
|
-
return currentCli;
|
|
12
|
-
}
|
|
13
|
-
return undefined;
|
|
14
|
-
};
|
|
15
|
-
|
|
16
|
-
const pnpmCli = (environment: NodeJS.ProcessEnv): string => {
|
|
17
|
-
const currentCli = currentPnpmCli(environment);
|
|
18
|
-
if (currentCli) return currentCli;
|
|
19
|
-
|
|
20
|
-
const installedCli = path.join(
|
|
21
|
-
path.dirname(process.execPath),
|
|
22
|
-
'node_modules',
|
|
23
|
-
'pnpm',
|
|
24
|
-
'bin',
|
|
25
|
-
'pnpm.cjs',
|
|
26
|
-
);
|
|
27
|
-
if (existsSync(installedCli)) return installedCli;
|
|
28
|
-
|
|
29
|
-
const corepackCli = path.join(
|
|
30
|
-
path.dirname(process.execPath),
|
|
31
|
-
'node_modules',
|
|
32
|
-
'corepack',
|
|
33
|
-
'dist',
|
|
34
|
-
'pnpm.js',
|
|
35
|
-
);
|
|
36
|
-
if (existsSync(corepackCli)) return corepackCli;
|
|
37
|
-
|
|
38
|
-
throw new Error('Cannot locate pnpm JavaScript CLI; npm_execpath must name the installed pnpm CLI');
|
|
39
|
-
};
|
|
40
|
-
|
|
41
|
-
/** Runs the workspace package manager without passing its arguments through a Windows shell. */
|
|
42
|
-
export const runPackageManager = (
|
|
43
|
-
args: string[],
|
|
44
|
-
options: Parameters<typeof exec>[2],
|
|
45
|
-
): ReturnType<typeof exec> => {
|
|
46
|
-
const environment = options?.env ?? process.env;
|
|
47
|
-
const currentCli = currentPnpmCli(environment);
|
|
48
|
-
if (currentCli) return exec(process.execPath, [currentCli, ...args], options);
|
|
49
|
-
if (process.platform !== 'win32') return exec('pnpm', args, options);
|
|
50
|
-
return exec(process.execPath, [pnpmCli(environment), ...args], options);
|
|
51
|
-
};
|
|
@@ -1,208 +0,0 @@
|
|
|
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 { chmod, mkdir, mkdtemp, readdir, rm, symlink, 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
|
-
it('refuses a request body larger than the cap with 413', async () => {
|
|
80
|
-
// Well-formed JSON on purpose: what is rejected is the size, not the shape.
|
|
81
|
-
// The server must answer before it has buffered the whole upload, and must
|
|
82
|
-
// stay readable long enough for the client to see that answer.
|
|
83
|
-
const body = JSON.stringify({ title: 'x'.repeat(1024 * 1024 + 1024) });
|
|
84
|
-
const response = await fetch(`${baseUrl}/notes`, {
|
|
85
|
-
method: 'POST',
|
|
86
|
-
headers: { 'content-type': 'application/json' },
|
|
87
|
-
body,
|
|
88
|
-
});
|
|
89
|
-
expect(response.status).toBe(413);
|
|
90
|
-
});
|
|
91
|
-
});
|
|
92
|
-
|
|
93
|
-
/** A listening server serving `staticDir`, plus its base URL. */
|
|
94
|
-
async function startStaticServer(staticDir: string): Promise<{ server: Server; base: string }> {
|
|
95
|
-
const server = makeServer(
|
|
96
|
-
{
|
|
97
|
-
notes: new JsonFileNoteStore(path.join(dir, 'data', 'notes.json')),
|
|
98
|
-
events: new SpoolEventPublisher(path.join(dir, 'queue'), () => 'm'),
|
|
99
|
-
newId: () => 'id',
|
|
100
|
-
now: () => '2024-01-01T00:00:00.000Z',
|
|
101
|
-
log: createLogger({}, () => {}),
|
|
102
|
-
},
|
|
103
|
-
{ staticDir },
|
|
104
|
-
);
|
|
105
|
-
await new Promise<void>((resolve) => server.listen(0, resolve));
|
|
106
|
-
return { server, base: `http://127.0.0.1:${(server.address() as AddressInfo).port}` };
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
// Windows has no POSIX mode bits, and root reads a 0o000 file regardless — in
|
|
110
|
-
// both cases the "unreadable file" this test needs cannot be built at all.
|
|
111
|
-
const cannotMakeFileUnreadable = process.platform === 'win32' || process.getuid?.() === 0;
|
|
112
|
-
// Creating a symlink on Windows needs a privilege the test runner may not hold.
|
|
113
|
-
const cannotSymlink = process.platform === 'win32';
|
|
114
|
-
|
|
115
|
-
describe('static serving (the built web bundle)', () => {
|
|
116
|
-
let staticServer: Server;
|
|
117
|
-
let staticBase: string;
|
|
118
|
-
|
|
119
|
-
beforeEach(async () => {
|
|
120
|
-
await mkdir(path.join(dir, 'static'), { recursive: true });
|
|
121
|
-
await writeFile(path.join(dir, 'static', 'index.html'), '<h1>web shell</h1>');
|
|
122
|
-
({ server: staticServer, base: staticBase } = await startStaticServer(
|
|
123
|
-
path.join(dir, 'static'),
|
|
124
|
-
));
|
|
125
|
-
});
|
|
126
|
-
|
|
127
|
-
afterEach(async () => {
|
|
128
|
-
await new Promise((resolve) => staticServer.close(resolve));
|
|
129
|
-
});
|
|
130
|
-
|
|
131
|
-
it('serves index.html at the root', async () => {
|
|
132
|
-
const response = await fetch(staticBase + '/');
|
|
133
|
-
expect(response.status).toBe(200);
|
|
134
|
-
expect(response.headers.get('content-type')).toContain('text/html');
|
|
135
|
-
expect(await response.text()).toContain('web shell');
|
|
136
|
-
});
|
|
137
|
-
|
|
138
|
-
it('refuses path traversal out of the static dir', async () => {
|
|
139
|
-
await writeFile(path.join(dir, 'secret.txt'), 'nope');
|
|
140
|
-
const response = await fetch(staticBase + '/%2e%2e/secret.txt');
|
|
141
|
-
expect(response.status).toBe(404);
|
|
142
|
-
});
|
|
143
|
-
|
|
144
|
-
it('API routes still win over static files', async () => {
|
|
145
|
-
const response = await fetch(staticBase + '/notes');
|
|
146
|
-
expect(response.status).toBe(200);
|
|
147
|
-
expect(response.headers.get('content-type')).toContain('application/json');
|
|
148
|
-
});
|
|
149
|
-
|
|
150
|
-
it('answers 400 to an undecodable URL and keeps serving afterwards', async () => {
|
|
151
|
-
// The signal is a leash, not a relaxation: a server that dies on this URL
|
|
152
|
-
// never answers at all, and the run should say so in seconds.
|
|
153
|
-
const response = await fetch(staticBase + '/%', { signal: AbortSignal.timeout(5000) });
|
|
154
|
-
expect(response.status).toBe(400);
|
|
155
|
-
|
|
156
|
-
const afterwards = await fetch(staticBase + '/');
|
|
157
|
-
expect(afterwards.status).toBe(200);
|
|
158
|
-
expect(await afterwards.text()).toContain('web shell');
|
|
159
|
-
});
|
|
160
|
-
|
|
161
|
-
it.skipIf(cannotMakeFileUnreadable)(
|
|
162
|
-
'answers a file it cannot open and keeps serving afterwards',
|
|
163
|
-
async () => {
|
|
164
|
-
// A file that passes stat() but fails open() (EACCES here; in production
|
|
165
|
-
// the stat/open race gives ENOENT). Before the headers are held back
|
|
166
|
-
// until the stream opens, that error reached no handler at all and the
|
|
167
|
-
// process paid for one unreadable file.
|
|
168
|
-
const locked = path.join(dir, 'static', 'locked.txt');
|
|
169
|
-
await writeFile(locked, 'unreadable');
|
|
170
|
-
await chmod(locked, 0o000);
|
|
171
|
-
try {
|
|
172
|
-
const response = await fetch(staticBase + '/locked.txt', {
|
|
173
|
-
signal: AbortSignal.timeout(5000),
|
|
174
|
-
});
|
|
175
|
-
expect(response.status).toBe(500);
|
|
176
|
-
|
|
177
|
-
const afterwards = await fetch(staticBase + '/');
|
|
178
|
-
expect(afterwards.status).toBe(200);
|
|
179
|
-
expect(await afterwards.text()).toContain('web shell');
|
|
180
|
-
} finally {
|
|
181
|
-
// Restore before afterEach removes the temp dir.
|
|
182
|
-
await chmod(locked, 0o600);
|
|
183
|
-
}
|
|
184
|
-
},
|
|
185
|
-
);
|
|
186
|
-
|
|
187
|
-
it.skipIf(cannotSymlink)('refuses a symlink that escapes the static dir', async () => {
|
|
188
|
-
// A lexical guard inspects only the resolved string, while stat and the
|
|
189
|
-
// read stream follow the link wherever it points — which is why the guard
|
|
190
|
-
// asks realpath as well.
|
|
191
|
-
await writeFile(path.join(dir, 'secret-target.txt'), 'nope');
|
|
192
|
-
await symlink(path.join(dir, 'secret-target.txt'), path.join(dir, 'static', 'escape.txt'));
|
|
193
|
-
|
|
194
|
-
const response = await fetch(staticBase + '/escape.txt');
|
|
195
|
-
expect(response.status).toBe(404);
|
|
196
|
-
});
|
|
197
|
-
|
|
198
|
-
it('serves the bundle when the configured static dir has a trailing separator', async () => {
|
|
199
|
-
const { server, base } = await startStaticServer(path.join(dir, 'static') + path.sep);
|
|
200
|
-
try {
|
|
201
|
-
const response = await fetch(base + '/');
|
|
202
|
-
expect(response.status).toBe(200);
|
|
203
|
-
expect(await response.text()).toContain('web shell');
|
|
204
|
-
} finally {
|
|
205
|
-
await new Promise((resolve) => server.close(resolve));
|
|
206
|
-
}
|
|
207
|
-
});
|
|
208
|
-
});
|
|
@@ -1,32 +0,0 @@
|
|
|
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
|
-
});
|
|
@@ -1,29 +0,0 @@
|
|
|
1
|
-
// The composition root resolves the built web bundle relative to itself; the
|
|
2
|
-
// resolution is a pure function so it can be tested without importing main.ts.
|
|
3
|
-
//
|
|
4
|
-
// This native absolute fixture exercises the platform root spelling and
|
|
5
|
-
// percent-decoding without injecting a path flavor into the production helper.
|
|
6
|
-
import path from 'node:path';
|
|
7
|
-
import { pathToFileURL } from 'node:url';
|
|
8
|
-
import { describe, expect, it } from 'vitest';
|
|
9
|
-
import { defaultStaticDirFor } from '../src/static-dir.js';
|
|
10
|
-
|
|
11
|
-
const moduleUrl = pathToFileURL(
|
|
12
|
-
path.join(path.parse(process.cwd()).root, 'a b', 'proj', 'services', 'api', 'src', 'main.ts'),
|
|
13
|
-
).href;
|
|
14
|
-
|
|
15
|
-
describe('resolving the default static dir from the module url', () => {
|
|
16
|
-
it('decodes percent-escapes in the path (a directory may contain a space)', () => {
|
|
17
|
-
const resolved = defaultStaticDirFor(moduleUrl);
|
|
18
|
-
|
|
19
|
-
expect(resolved).toContain('a b');
|
|
20
|
-
expect(resolved).not.toContain('%20');
|
|
21
|
-
});
|
|
22
|
-
|
|
23
|
-
it('points three levels up from services/api/src at apps/web/out', () => {
|
|
24
|
-
const resolved = defaultStaticDirFor(moduleUrl);
|
|
25
|
-
|
|
26
|
-
expect(resolved.endsWith(path.join('a b', 'proj', 'apps', 'web', 'out'))).toBe(true);
|
|
27
|
-
expect(path.isAbsolute(resolved)).toBe(true);
|
|
28
|
-
});
|
|
29
|
-
});
|
|
@@ -1,16 +0,0 @@
|
|
|
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
|
-
}
|
|
@@ -1,28 +0,0 @@
|
|
|
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();
|
|
@@ -1,60 +0,0 @@
|
|
|
1
|
-
// A directory is the queue: one JSON file per message. Retries are encoded in
|
|
2
|
-
// the filename (msg.json → msg.retry1.json → …); after maxAttempts the file
|
|
3
|
-
// moves to the DLQ directory and an alarm line is logged. Same discipline as
|
|
4
|
-
// any managed queue — visible failure, bounded retries, no silent loss.
|
|
5
|
-
import { mkdir, readFile, readdir, rename, rm } from 'node:fs/promises';
|
|
6
|
-
import path from 'node:path';
|
|
7
|
-
import type { Logger } from '@app/shared';
|
|
8
|
-
|
|
9
|
-
export interface SpoolConfig {
|
|
10
|
-
queueDir: string;
|
|
11
|
-
dlqDir: string;
|
|
12
|
-
/** Total attempts a message gets before it is dead-lettered. */
|
|
13
|
-
maxAttempts: number;
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
export interface SpoolDeps {
|
|
17
|
-
log: Logger;
|
|
18
|
-
process(raw: string): Promise<void>;
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
const RETRY_PATTERN = /\.retry(\d+)\.json$/;
|
|
22
|
-
|
|
23
|
-
const attemptOf = (name: string): number => {
|
|
24
|
-
const match = RETRY_PATTERN.exec(name);
|
|
25
|
-
return match ? Number.parseInt(match[1]!, 10) + 1 : 1;
|
|
26
|
-
};
|
|
27
|
-
|
|
28
|
-
const withRetrySuffix = (name: string, attempt: number): string =>
|
|
29
|
-
name.replace(/(\.retry\d+)?\.json$/, `.retry${attempt}.json`);
|
|
30
|
-
|
|
31
|
-
/** One pass over the queue. Returns how many messages were processed successfully. */
|
|
32
|
-
export async function processSpoolOnce(config: SpoolConfig, deps: SpoolDeps): Promise<number> {
|
|
33
|
-
await mkdir(config.queueDir, { recursive: true });
|
|
34
|
-
await mkdir(config.dlqDir, { recursive: true });
|
|
35
|
-
let processed = 0;
|
|
36
|
-
|
|
37
|
-
const entries = (await readdir(config.queueDir)).filter((name) => name.endsWith('.json')).sort();
|
|
38
|
-
for (const name of entries) {
|
|
39
|
-
const file = path.join(config.queueDir, name);
|
|
40
|
-
try {
|
|
41
|
-
await deps.process(await readFile(file, 'utf8'));
|
|
42
|
-
await rm(file);
|
|
43
|
-
processed += 1;
|
|
44
|
-
} catch (error) {
|
|
45
|
-
const attempt = attemptOf(name);
|
|
46
|
-
if (attempt >= config.maxAttempts) {
|
|
47
|
-
await rename(file, path.join(config.dlqDir, name));
|
|
48
|
-
deps.log.error('ALARM: message dead-lettered', {
|
|
49
|
-
file: name,
|
|
50
|
-
attempts: attempt,
|
|
51
|
-
error: String(error),
|
|
52
|
-
});
|
|
53
|
-
} else {
|
|
54
|
-
await rename(file, path.join(config.queueDir, withRetrySuffix(name, attempt)));
|
|
55
|
-
deps.log.warn('message failed, will retry', { file: name, attempt });
|
|
56
|
-
}
|
|
57
|
-
}
|
|
58
|
-
}
|
|
59
|
-
return processed;
|
|
60
|
-
}
|
package/templates/skeleton/node-service/services/worker/src/usecases/process-note-created.ts
DELETED
|
@@ -1,38 +0,0 @@
|
|
|
1
|
-
// Queue discipline: validate the message against the shared event schema; a
|
|
2
|
-
// message that does not parse is poison — throw, let the spool retry it, and
|
|
3
|
-
// after the retry budget the DLQ directory (watched by the alarm log line)
|
|
4
|
-
// catches it. Never swallow poison.
|
|
5
|
-
import { NoteCreatedEventSchema, type NoteCreatedEvent } from '@app/core';
|
|
6
|
-
import { ValidationError, type Logger } from '@app/shared';
|
|
7
|
-
|
|
8
|
-
export interface ProcessDeps {
|
|
9
|
-
log: Logger;
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
export function parseNoteCreatedEvent(raw: string): NoteCreatedEvent {
|
|
13
|
-
let candidate: unknown;
|
|
14
|
-
try {
|
|
15
|
-
candidate = JSON.parse(raw);
|
|
16
|
-
} catch {
|
|
17
|
-
throw new ValidationError('message body is not JSON');
|
|
18
|
-
}
|
|
19
|
-
const parsed = NoteCreatedEventSchema.safeParse(candidate);
|
|
20
|
-
if (!parsed.success) {
|
|
21
|
-
throw new ValidationError(
|
|
22
|
-
'message is not a note.created event',
|
|
23
|
-
parsed.error.issues.map((issue) => `${issue.path.join('.')}: ${issue.message}`),
|
|
24
|
-
);
|
|
25
|
-
}
|
|
26
|
-
return parsed.data;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
export async function processNoteCreated(raw: string, deps: ProcessDeps): Promise<void> {
|
|
30
|
-
const event = parseNoteCreatedEvent(raw);
|
|
31
|
-
// The skeleton's "processing" is deliberately small: acknowledge the note.
|
|
32
|
-
// Replace this with real downstream work; keep the validate-first shape.
|
|
33
|
-
deps.log.info('note.created processed', {
|
|
34
|
-
noteId: event.note.id,
|
|
35
|
-
slug: event.note.slug,
|
|
36
|
-
tagCount: event.note.tags.length,
|
|
37
|
-
});
|
|
38
|
-
}
|
|
@@ -1,34 +0,0 @@
|
|
|
1
|
-
import { createNote, makeNoteCreatedEvent } from '@app/core';
|
|
2
|
-
import { ValidationError, createLogger } from '@app/shared';
|
|
3
|
-
import { describe, expect, it } from 'vitest';
|
|
4
|
-
import { parseNoteCreatedEvent, processNoteCreated } from '../src/usecases/process-note-created.js';
|
|
5
|
-
|
|
6
|
-
const note = createNote({ title: 'Hello' }, { id: 'n1', createdAt: '2024-01-01T00:00:00.000Z' });
|
|
7
|
-
const validBody = JSON.stringify(makeNoteCreatedEvent(note));
|
|
8
|
-
|
|
9
|
-
describe('parseNoteCreatedEvent', () => {
|
|
10
|
-
it('parses a valid event', () => {
|
|
11
|
-
expect(parseNoteCreatedEvent(validBody).note.id).toBe('n1');
|
|
12
|
-
});
|
|
13
|
-
|
|
14
|
-
it('throws ValidationError for non-JSON bodies', () => {
|
|
15
|
-
expect(() => parseNoteCreatedEvent('not json')).toThrow(ValidationError);
|
|
16
|
-
});
|
|
17
|
-
|
|
18
|
-
it('throws ValidationError for JSON that is not a note.created event', () => {
|
|
19
|
-
expect(() => parseNoteCreatedEvent(JSON.stringify({ type: 'other' }))).toThrow(ValidationError);
|
|
20
|
-
});
|
|
21
|
-
});
|
|
22
|
-
|
|
23
|
-
describe('processNoteCreated', () => {
|
|
24
|
-
it('logs the processed note', async () => {
|
|
25
|
-
const lines: string[] = [];
|
|
26
|
-
await processNoteCreated(validBody, { log: createLogger({}, (line) => lines.push(line)) });
|
|
27
|
-
expect(lines.join('\n')).toContain('n1');
|
|
28
|
-
});
|
|
29
|
-
|
|
30
|
-
it('lets poison messages throw — the DLQ is the safety net, not a catch block', async () => {
|
|
31
|
-
const log = createLogger({}, () => {});
|
|
32
|
-
await expect(processNoteCreated('poison', { log })).rejects.toThrow(ValidationError);
|
|
33
|
-
});
|
|
34
|
-
});
|
|
@@ -1,76 +0,0 @@
|
|
|
1
|
-
import { mkdtemp, readdir, rm, writeFile } 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 { createLogger } from '@app/shared';
|
|
6
|
-
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
7
|
-
import { processNoteCreated } from '../src/usecases/process-note-created.js';
|
|
8
|
-
import { processSpoolOnce, type SpoolConfig } from '../src/spool.js';
|
|
9
|
-
|
|
10
|
-
const note = createNote({ title: 'Hello' }, { id: 'n1', createdAt: '2024-01-01T00:00:00.000Z' });
|
|
11
|
-
const validBody = JSON.stringify(makeNoteCreatedEvent(note));
|
|
12
|
-
|
|
13
|
-
let dir: string;
|
|
14
|
-
let config: SpoolConfig;
|
|
15
|
-
|
|
16
|
-
beforeEach(async () => {
|
|
17
|
-
dir = await mkdtemp(path.join(tmpdir(), 'spool-worker-'));
|
|
18
|
-
config = {
|
|
19
|
-
queueDir: path.join(dir, 'queue'),
|
|
20
|
-
dlqDir: path.join(dir, 'dlq'),
|
|
21
|
-
maxAttempts: 3,
|
|
22
|
-
};
|
|
23
|
-
});
|
|
24
|
-
|
|
25
|
-
afterEach(async () => {
|
|
26
|
-
await rm(dir, { recursive: true, force: true });
|
|
27
|
-
});
|
|
28
|
-
|
|
29
|
-
function makeDeps() {
|
|
30
|
-
const lines: string[] = [];
|
|
31
|
-
const log = createLogger({}, (line) => lines.push(line));
|
|
32
|
-
return { deps: { log, process: (raw: string) => processNoteCreated(raw, { log }) }, lines };
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
async function spool(name: string, content: string) {
|
|
36
|
-
await writeFile(path.join(config.queueDir, name), content);
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
describe('processSpoolOnce', () => {
|
|
40
|
-
it('processes valid messages and removes them', async () => {
|
|
41
|
-
const { deps, lines } = makeDeps();
|
|
42
|
-
await processSpoolOnce(config, deps); // creates the dirs
|
|
43
|
-
await spool('m1.json', validBody);
|
|
44
|
-
await spool('m2.json', validBody);
|
|
45
|
-
|
|
46
|
-
expect(await processSpoolOnce(config, deps)).toBe(2);
|
|
47
|
-
expect(await readdir(config.queueDir)).toEqual([]);
|
|
48
|
-
expect(await readdir(config.dlqDir)).toEqual([]);
|
|
49
|
-
expect(lines.filter((l) => l.includes('note.created processed'))).toHaveLength(2);
|
|
50
|
-
});
|
|
51
|
-
|
|
52
|
-
it('retries poison, then dead-letters it with an ALARM after maxAttempts', async () => {
|
|
53
|
-
const { deps, lines } = makeDeps();
|
|
54
|
-
await processSpoolOnce(config, deps);
|
|
55
|
-
await spool('poison.json', 'not json');
|
|
56
|
-
|
|
57
|
-
expect(await processSpoolOnce(config, deps)).toBe(0); // attempt 1 → retry1
|
|
58
|
-
expect(await readdir(config.queueDir)).toEqual(['poison.retry1.json']);
|
|
59
|
-
expect(await processSpoolOnce(config, deps)).toBe(0); // attempt 2 → retry2
|
|
60
|
-
expect(await readdir(config.queueDir)).toEqual(['poison.retry2.json']);
|
|
61
|
-
expect(await processSpoolOnce(config, deps)).toBe(0); // attempt 3 → DLQ
|
|
62
|
-
expect(await readdir(config.queueDir)).toEqual([]);
|
|
63
|
-
expect(await readdir(config.dlqDir)).toEqual(['poison.retry2.json']);
|
|
64
|
-
expect(lines.some((line) => line.includes('ALARM'))).toBe(true);
|
|
65
|
-
});
|
|
66
|
-
|
|
67
|
-
it('keeps processing good messages when one is poison', async () => {
|
|
68
|
-
const { deps } = makeDeps();
|
|
69
|
-
await processSpoolOnce(config, deps);
|
|
70
|
-
await spool('a-poison.json', '{broken');
|
|
71
|
-
await spool('b-good.json', validBody);
|
|
72
|
-
|
|
73
|
-
expect(await processSpoolOnce(config, deps)).toBe(1);
|
|
74
|
-
expect(await readdir(config.queueDir)).toEqual(['a-poison.retry1.json']);
|
|
75
|
-
});
|
|
76
|
-
});
|