@starci/hfs 1.0.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/README.md +38 -0
- package/bin/hfs.mjs +100 -0
- package/package.json +28 -0
- package/runtime/engine/runtime-root.mjs +32 -0
- package/runtime/engine/yaml.mjs +161 -0
- package/runtime/knowledge/hfs/canon-pins.yaml +212 -0
- package/runtime/knowledge/hfs/slots.yaml +842 -0
- package/runtime/modules/kernel/failure-codes.yaml +169 -0
- package/runtime/scripts/lib/glob.mjs +23 -0
- package/runtime/scripts/lib/hfs-check.mjs +305 -0
- package/runtime/scripts/lib/hfs-slots.mjs +675 -0
- package/runtime/scripts/lib/path-key.mjs +15 -0
- package/sync/cli.mjs +15 -0
- package/sync/hygiene.mjs +92 -0
- package/sync/index.mjs +224 -0
- package/sync/skeleton.mjs +54 -0
- package/sync/sonar-key.mjs +45 -0
- package/templates/be/e2e.yml +21 -0
- package/templates/be/gitignore +2 -0
- package/templates/be/pre-commit +8 -0
- package/templates/be/skeleton/apps/__app__/src/__app__.composition.spec.ts +31 -0
- package/templates/be/skeleton/apps/__app__/src/__app__.options.ts +7 -0
- package/templates/be/skeleton/apps/__app__/src/app.module.ts +23 -0
- package/templates/be/skeleton/apps/__app__/src/main.ts +20 -0
- package/templates/be/skeleton/src/features/system-health/index.ts +1 -0
- package/templates/be/skeleton/src/features/system-health/system-health.module.ts +6 -0
- package/templates/be/skeleton/src/features/system-health/transport/http/live.controller.spec.ts +13 -0
- package/templates/be/skeleton/src/features/system-health/transport/http/live.controller.ts +18 -0
- package/templates/be/skeleton/src/features/system-health/transport/http/system-health-http.module.ts +10 -0
- package/templates/be/skeleton/src/modules/platform/config/env-source.spec.ts +36 -0
- package/templates/be/skeleton/src/modules/platform/config/env-source.ts +40 -0
- package/templates/be/skeleton/src/modules/platform/config/errors/config.error.ts +21 -0
- package/templates/be/skeleton/src/modules/platform/config/index.ts +5 -0
- package/templates/be/skeleton/src/modules/platform/config/server.config.spec.ts +19 -0
- package/templates/be/skeleton/src/modules/platform/config/server.config.ts +15 -0
- package/templates/be/skeleton/src/modules/platform/config/server.options.ts +8 -0
- package/templates/be/skeleton/src/modules/platform/errors/domain-error.spec.ts +15 -0
- package/templates/be/skeleton/src/modules/platform/errors/domain-error.ts +11 -0
- package/templates/be/skeleton/src/modules/platform/errors/error.filter.spec.ts +38 -0
- package/templates/be/skeleton/src/modules/platform/errors/error.filter.ts +24 -0
- package/templates/be/skeleton/src/modules/platform/errors/index.ts +2 -0
- package/templates/be/skeleton/src/modules/platform/logging/index.ts +5 -0
- package/templates/be/skeleton/src/modules/platform/logging/json-logger.spec.ts +33 -0
- package/templates/be/skeleton/src/modules/platform/logging/json-logger.ts +35 -0
- package/templates/be/skeleton/src/modules/platform/logging/log-id.ts +9 -0
- package/templates/be/skeleton/src/modules/platform/logging/logger.port.ts +19 -0
- package/templates/be/skeleton/src/modules/platform/logging/logging.module.ts +11 -0
- package/templates/be/sonar-project.properties +10 -0
- package/templates/be/starciwork.gitignore +39 -0
- package/templates/common/ci.yml +50 -0
- package/templates/common/codecov.yml +13 -0
- package/templates/common/gitignore.base +33 -0
- package/templates/common/pre-push +5 -0
- package/templates/fe/e2e.yml +22 -0
- package/templates/fe/gitignore +3 -0
- package/templates/fe/pre-commit +7 -0
- package/templates/fe/skeleton/apps/__app__/next.config.ts +11 -0
- package/templates/fe/skeleton/apps/__app__/src/app/[locale]/error.tsx +22 -0
- package/templates/fe/skeleton/apps/__app__/src/app/[locale]/layout.tsx +31 -0
- package/templates/fe/skeleton/apps/__app__/src/app/[locale]/not-found.tsx +15 -0
- package/templates/fe/skeleton/apps/__app__/src/app/[locale]/page.tsx +27 -0
- package/templates/fe/skeleton/apps/__app__/src/app/global-error.tsx +24 -0
- package/templates/fe/skeleton/apps/__app__/src/app/globals.css +1 -0
- package/templates/fe/skeleton/apps/__app__/src/app/health/live/route.spec.ts +10 -0
- package/templates/fe/skeleton/apps/__app__/src/app/health/live/route.ts +5 -0
- package/templates/fe/skeleton/apps/__app__/src/modules/i18n/config.ts +8 -0
- package/templates/fe/skeleton/apps/__app__/src/modules/i18n/messages/vi.json +19 -0
- package/templates/fe/skeleton/apps/__app__/src/modules/i18n/messages.spec.ts +27 -0
- package/templates/fe/skeleton/apps/__app__/src/modules/i18n/navigation.ts +5 -0
- package/templates/fe/skeleton/apps/__app__/src/modules/i18n/request.ts +13 -0
- package/templates/fe/skeleton/apps/__app__/src/modules/i18n/routing.spec.ts +10 -0
- package/templates/fe/skeleton/apps/__app__/src/modules/i18n/routing.ts +9 -0
- package/templates/fe/skeleton/apps/__app__/src/proxy.ts +10 -0
- package/templates/fe/sonar-project.properties +11 -0
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { mkdtempSync, rmSync, writeFileSync } from "node:fs"
|
|
2
|
+
import { tmpdir } from "node:os"
|
|
3
|
+
import { join } from "node:path"
|
|
4
|
+
import { EnvSource } from "./env-source"
|
|
5
|
+
import { ConfigError } from "./errors/config.error"
|
|
6
|
+
|
|
7
|
+
describe("EnvSource", () => {
|
|
8
|
+
it("reads a key and treats an empty value as unset", () => {
|
|
9
|
+
const env = EnvSource.of({ PORT: "8080", EMPTY: "" })
|
|
10
|
+
expect(env.optional("PORT")).toBe("8080")
|
|
11
|
+
expect(env.optional("EMPTY")).toBeUndefined()
|
|
12
|
+
expect(env.optional("ABSENT")).toBeUndefined()
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
it("refuses a missing required key by naming it", () => {
|
|
16
|
+
const failure = () => EnvSource.of({}).required("DATABASE_URL")
|
|
17
|
+
expect(failure).toThrow(ConfigError)
|
|
18
|
+
expect(failure).toThrow("Configuration key DATABASE_URL is required and has no default.")
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
it("resolves <KEY>_FILE to the trimmed content of the file", () => {
|
|
22
|
+
const dir = mkdtempSync(join(tmpdir(), "env-source-"))
|
|
23
|
+
try {
|
|
24
|
+
const path = join(dir, "token")
|
|
25
|
+
writeFileSync(path, "value-from-file\n")
|
|
26
|
+
expect(EnvSource.of({ API_TOKEN_FILE: path }).required("API_TOKEN")).toBe("value-from-file")
|
|
27
|
+
} finally {
|
|
28
|
+
rmSync(dir, { recursive: true, force: true })
|
|
29
|
+
}
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it("names the _FILE key, not a path or a value, when the file cannot be read", () => {
|
|
33
|
+
const failure = () => EnvSource.of({ API_TOKEN_FILE: join(tmpdir(), "no-such-dir", "token") }).optional("API_TOKEN")
|
|
34
|
+
expect(failure).toThrow("Configuration key API_TOKEN_FILE points at a file that cannot be read.")
|
|
35
|
+
})
|
|
36
|
+
})
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs"
|
|
2
|
+
import { ConfigError } from "./errors/config.error"
|
|
3
|
+
|
|
4
|
+
type Values = Readonly<Record<string, string | undefined>>
|
|
5
|
+
|
|
6
|
+
/** The only reader of the process environment. Every key may instead be supplied as `<KEY>_FILE`, a path to its value. */
|
|
7
|
+
export class EnvSource {
|
|
8
|
+
private constructor(private readonly values: Values) {}
|
|
9
|
+
|
|
10
|
+
/** The live process environment; called once, from `main.ts`. */
|
|
11
|
+
static fromProcess(): EnvSource {
|
|
12
|
+
return new EnvSource(process.env)
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** A fixed snapshot, for specs and tools. */
|
|
16
|
+
static of(values: Values): EnvSource {
|
|
17
|
+
return new EnvSource(values)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** The value of `key`, or `undefined` when it is unset or empty. */
|
|
21
|
+
optional(key: string): string | undefined {
|
|
22
|
+
const path = this.values[`${key}_FILE`]
|
|
23
|
+
if (path !== undefined && path !== "") {
|
|
24
|
+
try {
|
|
25
|
+
return readFileSync(path, "utf8").trim()
|
|
26
|
+
} catch (cause) {
|
|
27
|
+
throw new ConfigError("CONFIG_FILE_UNREADABLE", `${key}_FILE`, { cause })
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
const value = this.values[key]
|
|
31
|
+
return value === undefined || value === "" ? undefined : value
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** The value of `key`; a missing key stops the boot with an error that names it. */
|
|
35
|
+
required(key: string): string {
|
|
36
|
+
const value = this.optional(key)
|
|
37
|
+
if (value === undefined) throw new ConfigError("CONFIG_KEY_MISSING", key)
|
|
38
|
+
return value
|
|
39
|
+
}
|
|
40
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { DomainError } from "@modules/platform/errors"
|
|
2
|
+
|
|
3
|
+
/** Why a configuration key was refused. */
|
|
4
|
+
export type ConfigErrorCode = "CONFIG_KEY_MISSING" | "CONFIG_KEY_INVALID" | "CONFIG_FILE_UNREADABLE"
|
|
5
|
+
|
|
6
|
+
const REASONS: Readonly<Record<ConfigErrorCode, string>> = {
|
|
7
|
+
CONFIG_KEY_MISSING: "is required and has no default",
|
|
8
|
+
CONFIG_KEY_INVALID: "does not hold a valid value",
|
|
9
|
+
CONFIG_FILE_UNREADABLE: "points at a file that cannot be read",
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** Startup configuration refusal: names the key and the rule it broke, never the value it was given. */
|
|
13
|
+
export class ConfigError extends DomainError {
|
|
14
|
+
/** The environment key that was refused. */
|
|
15
|
+
readonly key: string
|
|
16
|
+
|
|
17
|
+
constructor(code: ConfigErrorCode, key: string, options?: { readonly cause?: unknown }) {
|
|
18
|
+
super(code, `Configuration key ${key} ${REASONS[code]}.`, options)
|
|
19
|
+
this.key = key
|
|
20
|
+
}
|
|
21
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { EnvSource } from "./env-source"
|
|
2
|
+
import { ConfigError } from "./errors/config.error"
|
|
3
|
+
import { parseServerConfig } from "./server.config"
|
|
4
|
+
|
|
5
|
+
describe("parseServerConfig", () => {
|
|
6
|
+
it("falls back to port 3000 when PORT is unset", () => {
|
|
7
|
+
expect(parseServerConfig(EnvSource.of({}))).toEqual({ port: 3000 })
|
|
8
|
+
})
|
|
9
|
+
|
|
10
|
+
it("accepts an integer PORT inside the TCP range", () => {
|
|
11
|
+
expect(parseServerConfig(EnvSource.of({ PORT: "8080" }))).toEqual({ port: 8080 })
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
it.each(["80a", "0", "70000", "1.5"])("refuses PORT=%s by naming the key only", (port) => {
|
|
15
|
+
const failure = () => parseServerConfig(EnvSource.of({ PORT: port }))
|
|
16
|
+
expect(failure).toThrow(ConfigError)
|
|
17
|
+
expect(failure).toThrow("Configuration key PORT does not hold a valid value.")
|
|
18
|
+
})
|
|
19
|
+
})
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { EnvSource } from "./env-source"
|
|
2
|
+
import { ConfigError } from "./errors/config.error"
|
|
3
|
+
import type { ServerOptions } from "./server.options"
|
|
4
|
+
|
|
5
|
+
const DEFAULT_PORT = 3000
|
|
6
|
+
const MAX_PORT = 65535
|
|
7
|
+
|
|
8
|
+
/** Parses the server keys of an environment; a malformed PORT stops the boot. */
|
|
9
|
+
export const parseServerConfig = (env: EnvSource): ServerOptions => {
|
|
10
|
+
const raw = env.optional("PORT")
|
|
11
|
+
if (raw === undefined) return { port: DEFAULT_PORT }
|
|
12
|
+
const port = Number(raw)
|
|
13
|
+
if (!Number.isInteger(port) || port < 1 || port > MAX_PORT) throw new ConfigError("CONFIG_KEY_INVALID", "PORT")
|
|
14
|
+
return { port }
|
|
15
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/** The typed server configuration, validated once before the listener starts. */
|
|
2
|
+
export interface ServerOptions {
|
|
3
|
+
/** TCP port the HTTP listener binds. */
|
|
4
|
+
readonly port: number
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
/** Injection token under which the app module provides its {@link ServerOptions}. */
|
|
8
|
+
export const SERVER_OPTIONS = "SERVER_OPTIONS"
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { DomainError } from "./domain-error"
|
|
2
|
+
|
|
3
|
+
class SampleError extends DomainError {}
|
|
4
|
+
|
|
5
|
+
describe("DomainError", () => {
|
|
6
|
+
it("carries the code, the message and the cause, and is named after its subclass", () => {
|
|
7
|
+
const cause = new SyntaxError("bad input")
|
|
8
|
+
const error = new SampleError("SAMPLE_FAILED", "The sample failed.", { cause })
|
|
9
|
+
expect(error.code).toBe("SAMPLE_FAILED")
|
|
10
|
+
expect(error.message).toBe("The sample failed.")
|
|
11
|
+
expect(error.cause).toBe(cause)
|
|
12
|
+
expect(error.name).toBe("SampleError")
|
|
13
|
+
expect(error).toBeInstanceOf(Error)
|
|
14
|
+
})
|
|
15
|
+
})
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/** The base of every error a capability declares: a stable machine code, a sentence for people, and the cause. */
|
|
2
|
+
export abstract class DomainError extends Error {
|
|
3
|
+
/** Stable machine code that transports map to a status and logs group by. */
|
|
4
|
+
readonly code: string
|
|
5
|
+
|
|
6
|
+
constructor(code: string, message: string, options?: { readonly cause?: unknown }) {
|
|
7
|
+
super(message, options)
|
|
8
|
+
this.name = new.target.name
|
|
9
|
+
this.code = code
|
|
10
|
+
}
|
|
11
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { ArgumentsHost, HttpException, HttpStatus } from "@nestjs/common"
|
|
2
|
+
import { HttpArgumentsHost } from "@nestjs/common/interfaces"
|
|
3
|
+
import { mock } from "@starci/jest-preset/mock"
|
|
4
|
+
import type { Response } from "express"
|
|
5
|
+
import { Logger, LogId } from "@modules/platform/logging"
|
|
6
|
+
import { ErrorFilter } from "./error.filter"
|
|
7
|
+
|
|
8
|
+
const arrange = () => {
|
|
9
|
+
const response = mock<Response>()
|
|
10
|
+
response.status.mockReturnValue(response)
|
|
11
|
+
const http = mock<HttpArgumentsHost>()
|
|
12
|
+
http.getResponse.mockReturnValue(response)
|
|
13
|
+
const host = mock<ArgumentsHost>()
|
|
14
|
+
host.switchToHttp.mockReturnValue(http)
|
|
15
|
+
const logger = mock<Logger>()
|
|
16
|
+
return { response, host, logger, filter: new ErrorFilter(logger) }
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
describe("ErrorFilter", () => {
|
|
20
|
+
it("answers an unknown failure with a 500 that names no detail, and logs it once", () => {
|
|
21
|
+
const { response, host, logger, filter } = arrange()
|
|
22
|
+
filter.catch(new TypeError("secret detail"), host)
|
|
23
|
+
expect(response.status).toHaveBeenCalledWith(HttpStatus.INTERNAL_SERVER_ERROR)
|
|
24
|
+
expect(response.json).toHaveBeenCalledWith({ code: "internal_error" })
|
|
25
|
+
expect(logger.error).toHaveBeenCalledTimes(1)
|
|
26
|
+
expect(logger.error).toHaveBeenCalledWith(LogId.RequestFailed, {
|
|
27
|
+
status: HttpStatus.INTERNAL_SERVER_ERROR,
|
|
28
|
+
failure: { name: "TypeError" },
|
|
29
|
+
})
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it("keeps the status of a framework exception and answers with its status code only", () => {
|
|
33
|
+
const { response, host, filter } = arrange()
|
|
34
|
+
filter.catch(new HttpException("Not Found", HttpStatus.NOT_FOUND), host)
|
|
35
|
+
expect(response.status).toHaveBeenCalledWith(HttpStatus.NOT_FOUND)
|
|
36
|
+
expect(response.json).toHaveBeenCalledWith({ code: "http_404" })
|
|
37
|
+
})
|
|
38
|
+
})
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { ArgumentsHost, Catch, ExceptionFilter, HttpException, HttpStatus } from "@nestjs/common"
|
|
2
|
+
import type { Response } from "express"
|
|
3
|
+
import { Logger, LogId } from "@modules/platform/logging"
|
|
4
|
+
import { DomainError } from "./domain-error"
|
|
5
|
+
|
|
6
|
+
/** What a log line says about a failure: its name and code, never the stack or the request. */
|
|
7
|
+
const describeFailure = (exception: unknown): Readonly<Record<string, unknown>> =>
|
|
8
|
+
exception instanceof DomainError
|
|
9
|
+
? { name: exception.name, code: exception.code }
|
|
10
|
+
: { name: exception instanceof Error ? exception.name : typeof exception }
|
|
11
|
+
|
|
12
|
+
/** The one filter of an app: logs every failure once and answers with a status and a code that reveal nothing else. */
|
|
13
|
+
@Catch()
|
|
14
|
+
export class ErrorFilter implements ExceptionFilter {
|
|
15
|
+
constructor(private readonly logger: Logger) {}
|
|
16
|
+
|
|
17
|
+
/** Answers the request; an unknown failure is a 500 whose body names no detail. */
|
|
18
|
+
catch(exception: unknown, host: ArgumentsHost): void {
|
|
19
|
+
const status = exception instanceof HttpException ? exception.getStatus() : HttpStatus.INTERNAL_SERVER_ERROR
|
|
20
|
+
this.logger.error(LogId.RequestFailed, { status, failure: describeFailure(exception) })
|
|
21
|
+
const code = status === HttpStatus.INTERNAL_SERVER_ERROR ? "internal_error" : `http_${status}`
|
|
22
|
+
host.switchToHttp().getResponse<Response>().status(status).json({ code })
|
|
23
|
+
}
|
|
24
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { Writable } from "node:stream"
|
|
2
|
+
import { createJsonLogger } from "./json-logger"
|
|
3
|
+
import { LogId } from "./log-id"
|
|
4
|
+
|
|
5
|
+
const capture = () => {
|
|
6
|
+
const lines: string[] = []
|
|
7
|
+
const sink = new Writable({
|
|
8
|
+
write(chunk: Buffer, _encoding, done) {
|
|
9
|
+
lines.push(chunk.toString())
|
|
10
|
+
done()
|
|
11
|
+
},
|
|
12
|
+
})
|
|
13
|
+
return { lines, logger: createJsonLogger(sink) }
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
describe("createJsonLogger", () => {
|
|
17
|
+
it("writes one JSON line carrying the level, the enum identity, a timestamp and the payload", () => {
|
|
18
|
+
const { lines, logger } = capture()
|
|
19
|
+
logger.error(LogId.StartupFailed, { code: "CONFIG_KEY_MISSING" })
|
|
20
|
+
expect(lines).toHaveLength(1)
|
|
21
|
+
expect(lines[0].endsWith("\n")).toBe(true)
|
|
22
|
+
expect(JSON.parse(lines[0])).toMatchObject({ level: "error", id: "server.startup_failed", code: "CONFIG_KEY_MISSING" })
|
|
23
|
+
expect(JSON.parse(lines[0]).time).toEqual(expect.any(String))
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it("maps every level to its own name", () => {
|
|
27
|
+
const { lines, logger } = capture()
|
|
28
|
+
logger.debug(LogId.ServerStarted)
|
|
29
|
+
logger.info(LogId.ServerStarted)
|
|
30
|
+
logger.warn(LogId.ServerStarted)
|
|
31
|
+
expect(lines.map((line) => JSON.parse(line).level)).toEqual(["debug", "info", "warn"])
|
|
32
|
+
})
|
|
33
|
+
})
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { Writable } from "node:stream"
|
|
2
|
+
import type { LogId } from "./log-id"
|
|
3
|
+
import { Logger, LogPayload } from "./logger.port"
|
|
4
|
+
|
|
5
|
+
type Level = "debug" | "info" | "warn" | "error"
|
|
6
|
+
|
|
7
|
+
/** The default adapter: one JSON object per line on a stream, so a collector reads it without parsing prose. */
|
|
8
|
+
class JsonLogger extends Logger {
|
|
9
|
+
constructor(private readonly sink: Writable) {
|
|
10
|
+
super()
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
debug(id: LogId, payload?: LogPayload): void {
|
|
14
|
+
this.write("debug", id, payload)
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
info(id: LogId, payload?: LogPayload): void {
|
|
18
|
+
this.write("info", id, payload)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
warn(id: LogId, payload?: LogPayload): void {
|
|
22
|
+
this.write("warn", id, payload)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
error(id: LogId, payload?: LogPayload): void {
|
|
26
|
+
this.write("error", id, payload)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
private write(level: Level, id: LogId, payload?: LogPayload): void {
|
|
30
|
+
this.sink.write(`${JSON.stringify({ level, id, time: new Date().toISOString(), ...payload })}\n`)
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Builds the JSON logger; `sink` is stdout unless a caller (a spec, a process manager) supplies its own stream. */
|
|
35
|
+
export const createJsonLogger = (sink: Writable = process.stdout): Logger => new JsonLogger(sink)
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/** Every log line is named by a member of this enum, so a line can be found from the code that wrote it. */
|
|
2
|
+
export enum LogId {
|
|
3
|
+
/** The HTTP listener is bound and the app serves requests. */
|
|
4
|
+
ServerStarted = "server.started",
|
|
5
|
+
/** The app failed before it could serve; the process exits non-zero. */
|
|
6
|
+
StartupFailed = "server.startup_failed",
|
|
7
|
+
/** A request ended in a failure the app's filter answered. */
|
|
8
|
+
RequestFailed = "http.request_failed",
|
|
9
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { LogId } from "./log-id"
|
|
2
|
+
|
|
3
|
+
/** The structured data of one log line; values are plain data, never a request, a token or a secret. */
|
|
4
|
+
export type LogPayload = Readonly<Record<string, unknown>>
|
|
5
|
+
|
|
6
|
+
/** The logging port: capabilities log through it with an enum identity and a structured payload, never `console`. */
|
|
7
|
+
export abstract class Logger {
|
|
8
|
+
/** Detail for people debugging one environment. */
|
|
9
|
+
abstract debug(id: LogId, payload?: LogPayload): void
|
|
10
|
+
|
|
11
|
+
/** A normal event worth keeping. */
|
|
12
|
+
abstract info(id: LogId, payload?: LogPayload): void
|
|
13
|
+
|
|
14
|
+
/** Something unexpected the app recovered from. */
|
|
15
|
+
abstract warn(id: LogId, payload?: LogPayload): void
|
|
16
|
+
|
|
17
|
+
/** A failure the app could not recover from in place. */
|
|
18
|
+
abstract error(id: LogId, payload?: LogPayload): void
|
|
19
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { Global, Module } from "@nestjs/common"
|
|
2
|
+
import { createJsonLogger } from "./json-logger"
|
|
3
|
+
import { Logger } from "./logger.port"
|
|
4
|
+
|
|
5
|
+
/** Provides the logging port to every capability; one of the three platform modules that may be global. */
|
|
6
|
+
@Global()
|
|
7
|
+
@Module({
|
|
8
|
+
providers: [{ provide: Logger, useFactory: (): Logger => createJsonLogger() }],
|
|
9
|
+
exports: [Logger],
|
|
10
|
+
})
|
|
11
|
+
export class LoggingModule {}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
sonar.projectKey={{sonarKey}}
|
|
2
|
+
sonar.host.url=https://sonar.starci.org
|
|
3
|
+
sonar.sourceEncoding=UTF-8
|
|
4
|
+
sonar.sources=apps,src
|
|
5
|
+
sonar.tests=apps,src
|
|
6
|
+
sonar.exclusions={{sonarExclusions}}
|
|
7
|
+
sonar.test.inclusions=**/*.spec.ts
|
|
8
|
+
sonar.coverage.exclusions={{sonarCoverageExclusions}}
|
|
9
|
+
sonar.javascript.lcov.reportPaths=coverage/lcov.info
|
|
10
|
+
sonar.nodejs.maxspace=8192
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# StarCi .starciwork: product content only (ARCHITECTURE-DB §5.1, work-layout.yaml shape.productPaths).
|
|
2
|
+
# Agent output (reports, checks, captures, draw rounds, UAT runs, logs, ledgers, worktrees) lives in
|
|
3
|
+
# runtime.sqlite rows and blobs outside the repository.
|
|
4
|
+
/*
|
|
5
|
+
!/.gitignore
|
|
6
|
+
!/.gitattributes
|
|
7
|
+
!/workspace.yaml
|
|
8
|
+
!/index.yaml
|
|
9
|
+
!/brand/
|
|
10
|
+
!/shell/
|
|
11
|
+
!/_resources/
|
|
12
|
+
!/_derived/
|
|
13
|
+
!/features/
|
|
14
|
+
/brand/*
|
|
15
|
+
!/brand/index.yaml
|
|
16
|
+
!/brand/evidence.yaml
|
|
17
|
+
!/brand/assets/
|
|
18
|
+
/shell/*
|
|
19
|
+
!/shell/index.yaml
|
|
20
|
+
!/shell/evidence.yaml
|
|
21
|
+
/_resources/*
|
|
22
|
+
!/_resources/environments/
|
|
23
|
+
!/_resources/identities/
|
|
24
|
+
!/_resources/fixtures/
|
|
25
|
+
!/_resources/runtimes/
|
|
26
|
+
!/_resources/grammar-captures/
|
|
27
|
+
/_derived/*
|
|
28
|
+
!/_derived/index.yaml
|
|
29
|
+
!/_derived/frontier.md
|
|
30
|
+
!/_derived/critique.yaml
|
|
31
|
+
!/_derived/critique.md
|
|
32
|
+
evidence/
|
|
33
|
+
runs/
|
|
34
|
+
draw-loop/
|
|
35
|
+
operations/
|
|
36
|
+
report*.json
|
|
37
|
+
*.tmp.json
|
|
38
|
+
/features/**/impl/**/assets/
|
|
39
|
+
/shell/assets/
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
name: ci
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
pull_request:
|
|
7
|
+
|
|
8
|
+
permissions:
|
|
9
|
+
contents: read
|
|
10
|
+
id-token: write
|
|
11
|
+
|
|
12
|
+
jobs:
|
|
13
|
+
ci:
|
|
14
|
+
runs-on: ubuntu-latest
|
|
15
|
+
env:
|
|
16
|
+
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
|
|
17
|
+
steps:
|
|
18
|
+
- uses: actions/checkout@v4
|
|
19
|
+
- uses: actions/setup-node@v4
|
|
20
|
+
with:
|
|
21
|
+
node-version: {{nodeMajor}}
|
|
22
|
+
cache: npm
|
|
23
|
+
- run: npm ci
|
|
24
|
+
- name: hfs sync
|
|
25
|
+
run: npx hfs sync --check
|
|
26
|
+
- name: hfs check
|
|
27
|
+
run: npx hfs check
|
|
28
|
+
- name: lint
|
|
29
|
+
run: npm run lint:check
|
|
30
|
+
- name: typecheck
|
|
31
|
+
run: npm run typecheck
|
|
32
|
+
- name: unit
|
|
33
|
+
run: npm run test:ci
|
|
34
|
+
- name: build
|
|
35
|
+
run: npm run build
|
|
36
|
+
- uses: codecov/codecov-action@v5
|
|
37
|
+
with:
|
|
38
|
+
files: coverage/lcov.info
|
|
39
|
+
disable_search: true
|
|
40
|
+
fail_ci_if_error: true
|
|
41
|
+
use_oidc: true
|
|
42
|
+
- uses: SonarSource/sonarqube-scan-action@v7
|
|
43
|
+
if: env.SONAR_TOKEN != ''
|
|
44
|
+
env:
|
|
45
|
+
SONAR_HOST_URL: ${{ vars.SONAR_HOST_URL }}
|
|
46
|
+
- uses: SonarSource/sonarqube-quality-gate-action@v1
|
|
47
|
+
if: env.SONAR_TOKEN != ''
|
|
48
|
+
timeout-minutes: 10
|
|
49
|
+
env:
|
|
50
|
+
SONAR_HOST_URL: ${{ vars.SONAR_HOST_URL }}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# Build output and tool output the tools need in place.
|
|
2
|
+
node_modules/
|
|
3
|
+
dist/
|
|
4
|
+
coverage/
|
|
5
|
+
test-results/
|
|
6
|
+
playwright-report/
|
|
7
|
+
*.tsbuildinfo
|
|
8
|
+
.scannerwork/
|
|
9
|
+
.turbo/
|
|
10
|
+
.tools/
|
|
11
|
+
# Generated code is rebuilt by prebuild / pretypecheck, never tracked.
|
|
12
|
+
__generated__/
|
|
13
|
+
# Plaintext secrets never enter the tree; the sealed form is .starcistacks/<env>/secrets/<slug>.enc.
|
|
14
|
+
.env
|
|
15
|
+
.env.*
|
|
16
|
+
!.env.example
|
|
17
|
+
.secrets/
|
|
18
|
+
*.pem
|
|
19
|
+
# Agent output lives in the scratchpad and the blob store, never in the repository.
|
|
20
|
+
report*.json
|
|
21
|
+
*.tmp.json
|
|
22
|
+
*-lint.json
|
|
23
|
+
lf.json
|
|
24
|
+
lt.json
|
|
25
|
+
%*%
|
|
26
|
+
nul
|
|
27
|
+
.qwen*/
|
|
28
|
+
.artifacts/
|
|
29
|
+
.tmp-*
|
|
30
|
+
# Worktrees live under D:/starci-lanes, never inside the repository.
|
|
31
|
+
.worktrees/
|
|
32
|
+
# StarCi runtime state of the repository (ledger), never tracked.
|
|
33
|
+
.starci/
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# End-to-end runs are manual: nothing triggers this workflow but a person dispatching it.
|
|
2
|
+
name: e2e
|
|
3
|
+
|
|
4
|
+
on:
|
|
5
|
+
workflow_dispatch:
|
|
6
|
+
|
|
7
|
+
permissions:
|
|
8
|
+
contents: read
|
|
9
|
+
|
|
10
|
+
jobs:
|
|
11
|
+
e2e:
|
|
12
|
+
runs-on: ubuntu-latest
|
|
13
|
+
steps:
|
|
14
|
+
- uses: actions/checkout@v4
|
|
15
|
+
- uses: actions/setup-node@v4
|
|
16
|
+
with:
|
|
17
|
+
node-version: {{nodeMajor}}
|
|
18
|
+
cache: npm
|
|
19
|
+
- run: npm ci
|
|
20
|
+
- run: npx playwright install --with-deps chromium
|
|
21
|
+
- name: e2e
|
|
22
|
+
run: npm run test:e2e
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
# Commit gate: staged lint, types, the unit specs the staged files touch. Never e2e.
|
|
2
|
+
npx lint-staged
|
|
3
|
+
npm run typecheck
|
|
4
|
+
staged=$(git diff --cached --name-only --diff-filter=ACMR -- '*.ts' '*.tsx' | grep -v -E '(^|/)e2e/|\.e2e-spec\.ts$' || true)
|
|
5
|
+
if [ -n "$staged" ]; then
|
|
6
|
+
npx vitest related --run --passWithNoTests $staged
|
|
7
|
+
fi
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { NextConfig } from "next"
|
|
2
|
+
import createNextIntlPlugin from "next-intl/plugin"
|
|
3
|
+
|
|
4
|
+
const withNextIntl = createNextIntlPlugin("./src/modules/i18n/request.ts")
|
|
5
|
+
|
|
6
|
+
/** Next config of the {{app}} app: next-intl wired to the request config, Turbopack rooted at the repository. */
|
|
7
|
+
const nextConfig: NextConfig = {
|
|
8
|
+
turbopack: { root: process.cwd() },
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export default withNextIntl(nextConfig)
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import { useTranslations } from "next-intl"
|
|
4
|
+
|
|
5
|
+
interface ErrorPageProps {
|
|
6
|
+
readonly reset: () => void
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/** Boundary of the locale segment: a render failure shows this instead of a blank page, and retry re-renders the segment. */
|
|
10
|
+
const ErrorPage = ({ reset }: ErrorPageProps) => {
|
|
11
|
+
const t = useTranslations("errors.page")
|
|
12
|
+
return (
|
|
13
|
+
<main role="alert">
|
|
14
|
+
<h1>{t("title")}</h1>
|
|
15
|
+
<button type="button" onClick={reset}>
|
|
16
|
+
{t("retry")}
|
|
17
|
+
</button>
|
|
18
|
+
</main>
|
|
19
|
+
)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export default ErrorPage
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { hasLocale, NextIntlClientProvider } from "next-intl"
|
|
2
|
+
import { getMessages, setRequestLocale } from "next-intl/server"
|
|
3
|
+
import { notFound } from "next/navigation"
|
|
4
|
+
import type { ReactNode } from "react"
|
|
5
|
+
import { routing } from "../../modules/i18n/routing"
|
|
6
|
+
import "../globals.css"
|
|
7
|
+
|
|
8
|
+
interface LocaleLayoutProps {
|
|
9
|
+
readonly children: ReactNode
|
|
10
|
+
readonly params: Promise<{ readonly locale: string }>
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** One static shell per served locale. */
|
|
14
|
+
export const generateStaticParams = () => routing.locales.map((locale) => ({ locale }))
|
|
15
|
+
|
|
16
|
+
/** Root layout: sets `<html lang>` from the route segment and gives client components only the `errors` namespace. */
|
|
17
|
+
const LocaleLayout = async ({ children, params }: LocaleLayoutProps) => {
|
|
18
|
+
const { locale } = await params
|
|
19
|
+
if (!hasLocale(routing.locales, locale)) notFound()
|
|
20
|
+
setRequestLocale(locale)
|
|
21
|
+
const messages = await getMessages()
|
|
22
|
+
return (
|
|
23
|
+
<html lang={locale}>
|
|
24
|
+
<body className="min-h-dvh">
|
|
25
|
+
<NextIntlClientProvider messages={{ errors: messages.errors }}>{children}</NextIntlClientProvider>
|
|
26
|
+
</body>
|
|
27
|
+
</html>
|
|
28
|
+
)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export default LocaleLayout
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { getTranslations } from "next-intl/server"
|
|
2
|
+
import { Link } from "../../modules/i18n/navigation"
|
|
3
|
+
|
|
4
|
+
/** Shown when a route calls `notFound()`. */
|
|
5
|
+
const NotFound = async () => {
|
|
6
|
+
const t = await getTranslations("notFound")
|
|
7
|
+
return (
|
|
8
|
+
<main>
|
|
9
|
+
<h1>{t("title")}</h1>
|
|
10
|
+
<Link href="/">{t("home")}</Link>
|
|
11
|
+
</main>
|
|
12
|
+
)
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export default NotFound
|