mikoshi-construct 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.
- package/LICENSE +21 -0
- package/README.md +161 -0
- package/dist/cli.js +1492 -0
- package/package.json +73 -0
- package/templates/ai/claude/CLAUDE.md.eta +18 -0
- package/templates/ai/claude/CLAUDE.md.existing.eta +15 -0
- package/templates/ai/claude/_claude/agents/architect.md +38 -0
- package/templates/ai/claude/_claude/agents/harness.md +40 -0
- package/templates/ai/claude/_claude/agents/implementer.md +40 -0
- package/templates/ai/claude/_claude/commands/plan.md +24 -0
- package/templates/ai/claude/_claude/skills/implement/SKILL.md +42 -0
- package/templates/ai/claude/scripts/construct/implement.workflow.mjs +185 -0
- package/templates/ai/cursor/_cursor/rules/construct.mdc +19 -0
- package/templates/ai/review/_github/workflows/claude-review.yml.eta +51 -0
- package/templates/ai/shared/AGENTS.md.eta +123 -0
- package/templates/ai/shared/AGENTS.md.existing.eta +50 -0
- package/templates/ai/shared/_claude/commands/construct-discover.md +75 -0
- package/templates/ai/shared/_claude/rules/conventions.md +63 -0
- package/templates/ai/shared/_claude/rules/secrets.md +8 -0
- package/templates/ai/shared/_claude/rules/tests.md +6 -0
- package/templates/base/_editorconfig +12 -0
- package/templates/base/_github/workflows/security.yml +41 -0
- package/templates/base/_gitignore +11 -0
- package/templates/base/_gitleaks.toml +2 -0
- package/templates/base/_nvmrc.eta +1 -0
- package/templates/base/_vscode/settings.json +22 -0
- package/templates/base/architecture/checklists.md +42 -0
- package/templates/base/architecture/principles.md +113 -0
- package/templates/base/architecture/security-invariants.md +17 -0
- package/templates/harness/_github/workflows/ci.yml.eta +31 -0
- package/templates/harness/eslint.config.mjs +13 -0
- package/templates/harness/package.json.eta +29 -0
- package/templates/harness/pnpm-workspace.yaml +11 -0
- package/templates/harness/scripts/composition/check.ts +19 -0
- package/templates/harness/scripts/composition/files.ts +34 -0
- package/templates/harness/scripts/composition/model.ts +137 -0
- package/templates/harness/scripts/composition/render.ts +72 -0
- package/templates/harness/scripts/composition/sync-docs.ts +11 -0
- package/templates/harness/scripts/tests/composition/files.test.ts +16 -0
- package/templates/harness/scripts/tests/composition/model.test.ts +41 -0
- package/templates/harness/scripts/tests/composition/render.test.ts +54 -0
- package/templates/harness/tsconfig.base.json +14 -0
- package/templates/harness/tsconfig.json +5 -0
- package/templates/harness/vitest.config.ts +8 -0
- package/templates/presets/monorepo/baseline/eslint.config.mjs.eta +100 -0
- package/templates/presets/monorepo/baseline/package.json.eta +18 -0
- package/templates/presets/monorepo/baseline/packages/shared/package.json.eta +22 -0
- package/templates/presets/monorepo/baseline/packages/shared/src/api/openapi.ts +81 -0
- package/templates/presets/monorepo/baseline/packages/shared/src/index.ts +1 -0
- package/templates/presets/monorepo/baseline/packages/shared/tsconfig.build.json +10 -0
- package/templates/presets/monorepo/baseline/packages/shared/tsconfig.json +4 -0
- package/templates/presets/monorepo/baseline/pnpm-workspace.yaml.eta +31 -0
- package/templates/presets/monorepo/baseline/tsconfig.json +12 -0
- package/templates/presets/monorepo/baseline/vitest.config.ts +8 -0
- package/templates/presets/monorepo/sample/apps/api/package.json.eta +23 -0
- package/templates/presets/monorepo/sample/apps/api/tsconfig.build.json +10 -0
- package/templates/presets/monorepo/sample/apps/api/tsconfig.json +4 -0
- package/templates/presets/monorepo/sample/scripts/tests/lint/syntax-policy.test.ts.eta +36 -0
- package/templates/presets/node-backend/baseline/eslint.config.mjs +59 -0
- package/templates/presets/node-backend/baseline/package.json.eta +20 -0
- package/templates/presets/node-backend/baseline/src/contracts/openapi.ts +81 -0
- package/templates/presets/node-backend/baseline/tsconfig.build.json +10 -0
- package/templates/presets/node-backend/sample/scripts/tests/lint/syntax-policy.test.ts +31 -0
- package/templates/presets/node-frontend/baseline/_claude/rules/css.md +158 -0
- package/templates/presets/node-frontend/baseline/eslint.config.mjs +44 -0
- package/templates/presets/node-frontend/baseline/package.json.eta +15 -0
- package/templates/presets/node-frontend/baseline/tsconfig.json +11 -0
- package/templates/presets/node-frontend/baseline/vitest.config.ts +10 -0
- package/templates/presets/node-frontend/sample/architecture/app.md +37 -0
- package/templates/presets/node-frontend/sample/architecture/composition/app.yaml +41 -0
- package/templates/presets/node-frontend/sample/index.html.eta +12 -0
- package/templates/presets/node-frontend/sample/src/app.ts +32 -0
- package/templates/presets/node-frontend/sample/src/main.ts +9 -0
- package/templates/presets/node-frontend/sample/src/styles/app.css +43 -0
- package/templates/presets/node-frontend/sample/src/styles/tokens.css +16 -0
- package/templates/presets/node-frontend/sample/tests/app.test.ts +21 -0
- package/templates/stacks/express-api/app/src/app.ts +27 -0
- package/templates/stacks/express-api/app/src/config.ts.eta +30 -0
- package/templates/stacks/express-api/app/src/contracts/types.ts.eta +1 -0
- package/templates/stacks/express-api/app/src/health/health.controller.ts +12 -0
- package/templates/stacks/express-api/app/src/health/health.service.ts +23 -0
- package/templates/stacks/express-api/app/src/http/api-contract.middleware.ts +13 -0
- package/templates/stacks/express-api/app/src/http/app-error.ts +14 -0
- package/templates/stacks/express-api/app/src/http/async-handler.middleware.ts +7 -0
- package/templates/stacks/express-api/app/src/http/error-handler.middleware.ts +50 -0
- package/templates/stacks/express-api/app/src/server.ts +9 -0
- package/templates/stacks/express-api/app/tests/health.test.ts +28 -0
- package/templates/stacks/express-api/app/tests/http/error-handler.middleware.test.ts +48 -0
- package/templates/stacks/express-api/repo/architecture/composition/http.yaml.eta +45 -0
- package/templates/stacks/express-api/repo/architecture/http.md +39 -0
- package/templates/stacks/http-contract/_github/workflows/api-contract.yml +36 -0
- package/templates/stacks/http-contract/contracts/api/openapi.yaml.eta +62 -0
- package/templates/stacks/http-contract/package.json.eta +13 -0
- package/templates/stacks/http-contract/redocly.yaml.eta +8 -0
- package/templates/stacks/http-contract/scripts/contracts/types.mjs.eta +20 -0
- package/templates/stacks/http-contract/scripts/tests/contracts/security.test.ts.eta +86 -0
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
openapi: 3.1.0
|
|
2
|
+
info:
|
|
3
|
+
title: {{projectName}} API
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
description: What this service promises to its consumers. API changes start here; the implementation conforms to the contract and consumers read the generated types.
|
|
6
|
+
security:
|
|
7
|
+
- {}
|
|
8
|
+
servers:
|
|
9
|
+
- url: /
|
|
10
|
+
description: Same origin. Local development serves the contract on the port PORT names, 3000 by default.
|
|
11
|
+
tags:
|
|
12
|
+
- name: public
|
|
13
|
+
description: Anonymous endpoints.
|
|
14
|
+
paths:
|
|
15
|
+
/health:
|
|
16
|
+
get:
|
|
17
|
+
operationId: getHealth
|
|
18
|
+
tags: [public]
|
|
19
|
+
summary: Liveness
|
|
20
|
+
responses:
|
|
21
|
+
'200':
|
|
22
|
+
description: The service is up.
|
|
23
|
+
content:
|
|
24
|
+
application/json:
|
|
25
|
+
schema:
|
|
26
|
+
$ref: '#/components/schemas/HealthResponse'
|
|
27
|
+
'503':
|
|
28
|
+
description: A dependency did not answer.
|
|
29
|
+
content:
|
|
30
|
+
application/json:
|
|
31
|
+
schema:
|
|
32
|
+
$ref: '#/components/schemas/HealthResponse'
|
|
33
|
+
default:
|
|
34
|
+
description: Unexpected error.
|
|
35
|
+
content:
|
|
36
|
+
application/json:
|
|
37
|
+
schema:
|
|
38
|
+
$ref: '#/components/schemas/ErrorResponse'
|
|
39
|
+
components:
|
|
40
|
+
schemas:
|
|
41
|
+
HealthResponse:
|
|
42
|
+
type: object
|
|
43
|
+
additionalProperties: false
|
|
44
|
+
required: [status, service, time]
|
|
45
|
+
properties:
|
|
46
|
+
status:
|
|
47
|
+
type: string
|
|
48
|
+
enum: [ok, error]
|
|
49
|
+
service:
|
|
50
|
+
type: string
|
|
51
|
+
time:
|
|
52
|
+
type: string
|
|
53
|
+
format: date-time
|
|
54
|
+
ErrorResponse:
|
|
55
|
+
type: object
|
|
56
|
+
additionalProperties: false
|
|
57
|
+
required: [error, message]
|
|
58
|
+
properties:
|
|
59
|
+
error:
|
|
60
|
+
type: string
|
|
61
|
+
message:
|
|
62
|
+
type: string
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "{{projectName}}",
|
|
3
|
+
"scripts": {
|
|
4
|
+
"contracts:lint": "redocly lint",
|
|
5
|
+
"contracts:types": "node scripts/contracts/types.mjs",
|
|
6
|
+
"contracts:check": "pnpm contracts:lint && node scripts/contracts/types.mjs --check",
|
|
7
|
+
"quality": "pnpm contracts:check && pnpm composition:check && pnpm lint && pnpm typecheck && pnpm test"
|
|
8
|
+
},
|
|
9
|
+
"devDependencies": {
|
|
10
|
+
"@redocly/cli": "^2.52.0",
|
|
11
|
+
"openapi-typescript": "^7.13.0"
|
|
12
|
+
}
|
|
13
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync } from 'node:fs'
|
|
2
|
+
import process from 'node:process'
|
|
3
|
+
import { pathToFileURL } from 'node:url'
|
|
4
|
+
import openapiTS, { astToString } from 'openapi-typescript'
|
|
5
|
+
|
|
6
|
+
const CONTRACT = '{{contractPath}}'
|
|
7
|
+
const OUTPUT = '{{contractTypesOutput}}'
|
|
8
|
+
|
|
9
|
+
const generated = astToString(await openapiTS(pathToFileURL(CONTRACT)))
|
|
10
|
+
|
|
11
|
+
if (process.argv.includes('--check')) {
|
|
12
|
+
const committed = readFileSync(OUTPUT, 'utf8')
|
|
13
|
+
if (committed !== generated) {
|
|
14
|
+
console.error(`${OUTPUT} is out of date with ${CONTRACT}; run pnpm contracts:types`)
|
|
15
|
+
process.exit(1)
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
else {
|
|
19
|
+
writeFileSync(OUTPUT, generated)
|
|
20
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs'
|
|
2
|
+
import { describe, expect, it } from 'vitest'
|
|
3
|
+
import { parse } from 'yaml'
|
|
4
|
+
|
|
5
|
+
interface SchemaNode {
|
|
6
|
+
$ref?: string
|
|
7
|
+
additionalProperties?: boolean | SchemaNode
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
interface MediaTypes {
|
|
11
|
+
content?: Record<string, { schema: SchemaNode }>
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
interface Operation {
|
|
15
|
+
operationId: string
|
|
16
|
+
security?: Array<Record<string, unknown>>
|
|
17
|
+
requestBody?: MediaTypes
|
|
18
|
+
responses?: Record<string, MediaTypes>
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
interface ContractDocument {
|
|
22
|
+
paths: Record<string, Record<string, Operation>>
|
|
23
|
+
components: { schemas: Record<string, SchemaNode> }
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const contract = parse(readFileSync(new URL('../../../{{contractPath}}', import.meta.url), 'utf8')) as ContractDocument
|
|
27
|
+
|
|
28
|
+
const PROTECTED_PREFIXES = ['/v1/admin', '/v1/me']
|
|
29
|
+
|
|
30
|
+
function operations(prefix: string): Array<{ route: string, method: string, operation: Operation }> {
|
|
31
|
+
return Object.entries(contract.paths)
|
|
32
|
+
.filter(([route]) => route.startsWith(prefix))
|
|
33
|
+
.flatMap(([route, methods]) => Object.entries(methods).map(([method, operation]) => ({ route, method, operation })))
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function resolveSchema(schema: SchemaNode): SchemaNode {
|
|
37
|
+
return schema.$ref == null
|
|
38
|
+
? schema
|
|
39
|
+
: resolveSchema(contract.components.schemas[schema.$ref.replace('#/components/schemas/', '')])
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function jsonRequestBodies(): Array<{ route: string, method: string, schema: SchemaNode }> {
|
|
43
|
+
return Object.entries(contract.paths).flatMap(([route, methods]) =>
|
|
44
|
+
Object.entries(methods).flatMap(([method, operation]) => {
|
|
45
|
+
const schema = operation.requestBody?.content?.['application/json']?.schema
|
|
46
|
+
return schema == null ? [] : [{ route, method, schema: resolveSchema(schema) }]
|
|
47
|
+
}))
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function jsonResponseBodies(): Array<{ route: string, method: string, status: string, schema: SchemaNode }> {
|
|
51
|
+
return Object.entries(contract.paths).flatMap(([route, methods]) =>
|
|
52
|
+
Object.entries(methods).flatMap(([method, operation]) =>
|
|
53
|
+
Object.entries(operation.responses ?? {}).flatMap(([status, response]) => {
|
|
54
|
+
const schema = response.content?.['application/json']?.schema
|
|
55
|
+
return schema == null ? [] : [{ route, method, status, schema: resolveSchema(schema) }]
|
|
56
|
+
})))
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function requires(operation: Operation, scheme: string): boolean {
|
|
60
|
+
const schemes = operation.security ?? []
|
|
61
|
+
return schemes.length > 0 && schemes.every(entry => scheme in entry)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
describe('security invariants stated by the contract', () => {
|
|
65
|
+
it('declares every operation with an operationId', () => {
|
|
66
|
+
for (const { route, method, operation } of operations('/'))
|
|
67
|
+
expect({ route, method, operationId: typeof operation.operationId }).toEqual({ route, method, operationId: 'string' })
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it('requires a bearer session on every operation under a protected prefix', () => {
|
|
71
|
+
for (const prefix of PROTECTED_PREFIXES) {
|
|
72
|
+
for (const { route, method, operation } of operations(prefix))
|
|
73
|
+
expect({ route, method, bearer: requires(operation, 'bearer') }).toEqual({ route, method, bearer: true })
|
|
74
|
+
}
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it('closes every documented JSON request body, so no write can set a field the contract does not expose', () => {
|
|
78
|
+
for (const { route, method, schema } of jsonRequestBodies())
|
|
79
|
+
expect({ route, method, closed: schema.additionalProperties }).toEqual({ route, method, closed: false })
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it('closes every documented JSON response body, so no response can leak a field the contract does not expose', () => {
|
|
83
|
+
for (const { route, method, status, schema } of jsonResponseBodies())
|
|
84
|
+
expect({ route, method, status, closed: schema.additionalProperties }).toEqual({ route, method, status, closed: false })
|
|
85
|
+
})
|
|
86
|
+
})
|