@zerotal/core 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/CHANGELOG.md +79 -0
- package/LICENSE +21 -0
- package/README.md +128 -0
- package/package.json +72 -0
- package/src/application/Application.ts +1671 -0
- package/src/application/BootDoctor.ts +108 -0
- package/src/application/DevErrorPage.ts +567 -0
- package/src/application/ExceptionHandler.ts +183 -0
- package/src/application/currentApp.ts +73 -0
- package/src/assets/assets.ts +79 -0
- package/src/assets/index.ts +16 -0
- package/src/auth/AuthenticatedUser.ts +18 -0
- package/src/build/PackageLinter.ts +146 -0
- package/src/build/PackageScaffold.ts +127 -0
- package/src/build/codemod.ts +64 -0
- package/src/build/index.ts +12 -0
- package/src/command/Command.ts +254 -0
- package/src/command/CommandRunner.ts +593 -0
- package/src/command/OutputWriter.ts +61 -0
- package/src/command/builtin/CompileCommand.ts +46 -0
- package/src/command/builtin/CssBuildCommand.ts +71 -0
- package/src/command/builtin/KeyGenerateCommand.ts +58 -0
- package/src/command/builtin/LintPackagesCommand.ts +72 -0
- package/src/command/builtin/MakeCommandCommand.ts +85 -0
- package/src/command/builtin/MakeControllerCommand.ts +95 -0
- package/src/command/builtin/MakeEventCommand.ts +85 -0
- package/src/command/builtin/MakeJobCommand.ts +53 -0
- package/src/command/builtin/MakeListenerCommand.ts +35 -0
- package/src/command/builtin/MakeMiddlewareCommand.ts +63 -0
- package/src/command/builtin/MakeNotificationCommand.ts +48 -0
- package/src/command/builtin/MakeObserverCommand.ts +78 -0
- package/src/command/builtin/MakePackageCommand.ts +45 -0
- package/src/command/builtin/MakePolicyCommand.ts +66 -0
- package/src/command/builtin/MakeProviderCommand.ts +75 -0
- package/src/command/builtin/MakeRequestCommand.ts +47 -0
- package/src/command/builtin/MakeResourceCommand.ts +61 -0
- package/src/command/builtin/MakeTestCommand.ts +120 -0
- package/src/command/builtin/ReloadCommand.ts +52 -0
- package/src/command/builtin/ReplCommand.ts +174 -0
- package/src/command/builtin/RouteListCommand.ts +188 -0
- package/src/command/builtin/ServeCommand.ts +321 -0
- package/src/command/builtin/StartCommand.ts +3 -0
- package/src/command/builtin/StatusCommand.ts +71 -0
- package/src/command/builtin/TestCommand.ts +172 -0
- package/src/command/builtin/WorkerCommand.ts +27 -0
- package/src/command/builtin/index.ts +53 -0
- package/src/command/scaffold/worker.ts.txt +12 -0
- package/src/command/scaffold/zerotal.ts.txt +26 -0
- package/src/command/startZerotal.ts +55 -0
- package/src/config/AppConfig.ts +253 -0
- package/src/config/ConfigLoader.ts +117 -0
- package/src/config/ConfigManager.ts +169 -0
- package/src/config/index.ts +46 -0
- package/src/config/registry.ts +59 -0
- package/src/config/validation.ts +117 -0
- package/src/container/Container.ts +606 -0
- package/src/container/ContextualBindingBuilder.ts +57 -0
- package/src/container/ScopedResolver.ts +117 -0
- package/src/container/index.ts +32 -0
- package/src/container/inject.ts +55 -0
- package/src/container/types.ts +71 -0
- package/src/context/RequestContext.ts +91 -0
- package/src/contracts/auth.ts +24 -0
- package/src/contracts/index.ts +23 -0
- package/src/contracts/session.ts +70 -0
- package/src/contracts/transaction.ts +26 -0
- package/src/conventions/ConventionLoader.ts +128 -0
- package/src/conventions/builtinConcerns.ts +131 -0
- package/src/crypt/Crypt.ts +141 -0
- package/src/crypt/URLSigner.ts +96 -0
- package/src/datetime/Carbon.ts +1396 -0
- package/src/datetime/CarbonInterval.ts +421 -0
- package/src/datetime/clock.ts +28 -0
- package/src/datetime/index.ts +23 -0
- package/src/datetime/temporal-shim.ts +1 -0
- package/src/dev/BuildOutput.ts +131 -0
- package/src/dev/CssPlugins.ts +184 -0
- package/src/dev/DevBuildHook.ts +74 -0
- package/src/dev/DevOrchestrator.ts +213 -0
- package/src/dev/DevReloadMiddleware.ts +101 -0
- package/src/dev/DevReloadServer.ts +85 -0
- package/src/dev/DevWsServer.ts +45 -0
- package/src/dev/index.ts +19 -0
- package/src/dev/reloadClient.ts +39 -0
- package/src/env/Def.ts +232 -0
- package/src/env/EnvSchema.ts +105 -0
- package/src/env/index.ts +34 -0
- package/src/env/t.ts +128 -0
- package/src/errors/ConfigError.ts +12 -0
- package/src/errors/ContainerErrors.ts +143 -0
- package/src/errors/HttpError.ts +127 -0
- package/src/errors/ValidationError.ts +19 -0
- package/src/errors/ZerotalError.ts +25 -0
- package/src/errors/index.ts +46 -0
- package/src/events/CallQueuedListener.ts +66 -0
- package/src/events/Emitter.ts +280 -0
- package/src/events/EventFake.ts +160 -0
- package/src/events/FrameworkEvents.ts +252 -0
- package/src/facade/Facade.ts +101 -0
- package/src/facade/facades/App.ts +155 -0
- package/src/facade/facades/Artisan.ts +63 -0
- package/src/facade/facades/Config.ts +21 -0
- package/src/facade/facades/Events.ts +19 -0
- package/src/facade/facades/index.ts +28 -0
- package/src/global.d.ts +9 -0
- package/src/hash/Hash.ts +60 -0
- package/src/health/Health.ts +221 -0
- package/src/health/index.ts +27 -0
- package/src/helpers/Collection.ts +435 -0
- package/src/helpers/config.ts +59 -0
- package/src/helpers/fluent.ts +52 -0
- package/src/helpers/html.ts +11 -0
- package/src/helpers/index.ts +266 -0
- package/src/helpers/make.ts +35 -0
- package/src/helpers/markdown.ts +73 -0
- package/src/helpers/pageElements.ts +27 -0
- package/src/helpers/request.ts +62 -0
- package/src/helpers/response.ts +411 -0
- package/src/helpers/str.ts +208 -0
- package/src/http/Http.ts +298 -0
- package/src/http/HttpClient.ts +289 -0
- package/src/http/Resource.ts +171 -0
- package/src/http/UploadedFile.ts +204 -0
- package/src/http/Uri.ts +490 -0
- package/src/http/index.ts +46 -0
- package/src/http/negotiate.ts +213 -0
- package/src/http/originGuard.ts +76 -0
- package/src/http/sniffContentType.ts +105 -0
- package/src/http/url.ts +204 -0
- package/src/http/withHeaders.ts +24 -0
- package/src/index.ts +250 -0
- package/src/lock/LockManager.ts +228 -0
- package/src/lock/config.ts +49 -0
- package/src/lock/drivers/LockDriver.ts +32 -0
- package/src/lock/drivers/MemoryLockDriver.ts +52 -0
- package/src/lock/drivers/RedisLockDriver.ts +58 -0
- package/src/lock/drivers/SqliteLockDriver.ts +85 -0
- package/src/lock/errors.ts +20 -0
- package/src/lock/facades/Lock.ts +114 -0
- package/src/lock/index.ts +53 -0
- package/src/logger/Log.ts +35 -0
- package/src/logger/LogManager.ts +430 -0
- package/src/logger/LoggerMiddleware.ts +125 -0
- package/src/logger/channels/ConsoleChannel.ts +139 -0
- package/src/logger/channels/DailyChannel.ts +74 -0
- package/src/logger/channels/NullChannel.ts +17 -0
- package/src/logger/channels/SingleChannel.ts +34 -0
- package/src/logger/channels/StackChannel.ts +29 -0
- package/src/logger/config.ts +90 -0
- package/src/logger/format.ts +96 -0
- package/src/logger/frameworkLog.ts +93 -0
- package/src/logger/index.ts +68 -0
- package/src/logger/renderTable.ts +111 -0
- package/src/logger/types.ts +212 -0
- package/src/macros/config.macro.ts +50 -0
- package/src/metrics/HttpMetrics.ts +114 -0
- package/src/metrics/index.ts +18 -0
- package/src/middleware/BaseMiddleware.ts +72 -0
- package/src/middleware/CorsMiddleware.ts +152 -0
- package/src/middleware/RateLimiter.ts +255 -0
- package/src/middleware/SecureHeadersMiddleware.ts +127 -0
- package/src/middleware/ThrottleMiddleware.ts +252 -0
- package/src/middleware/WebhookMiddleware.ts +204 -0
- package/src/pipeline/ContextRegistry.ts +42 -0
- package/src/pipeline/HttpContext.ts +865 -0
- package/src/pipeline/Pipeline.ts +150 -0
- package/src/pipeline/currentPage.ts +46 -0
- package/src/pipeline/types.ts +80 -0
- package/src/provider/LockProvider.ts +64 -0
- package/src/provider/LogProvider.ts +137 -0
- package/src/provider/ServiceProvider.ts +84 -0
- package/src/provider/StorageProvider.ts +45 -0
- package/src/router/FileRouter.ts +526 -0
- package/src/router/Route.ts +76 -0
- package/src/router/RouteHandler.ts +335 -0
- package/src/router/Router.ts +1247 -0
- package/src/router/domain.ts +65 -0
- package/src/security/index.ts +22 -0
- package/src/storage/FakeDisk.ts +233 -0
- package/src/storage/StorageFilesMiddleware.ts +150 -0
- package/src/storage/StorageManager.ts +173 -0
- package/src/storage/config.ts +47 -0
- package/src/storage/drivers/LocalDriver.ts +138 -0
- package/src/storage/drivers/S3Driver.ts +169 -0
- package/src/storage/errors.ts +135 -0
- package/src/storage/facades/Storage.ts +3 -0
- package/src/storage/global.d.ts +7 -0
- package/src/storage/index.ts +22 -0
- package/src/storage/root.ts +59 -0
- package/src/storage/types.ts +104 -0
- package/src/support/appKey.ts +38 -0
- package/src/support/cookie.ts +72 -0
- package/src/support/crypto.ts +52 -0
- package/src/support/deepMerge.ts +117 -0
- package/src/support/env.ts +71 -0
- package/src/support/network.ts +79 -0
- package/src/support/port.ts +197 -0
- package/src/support/str.ts +122 -0
- package/src/view/FileRouteResolver.ts +59 -0
- package/src/view/index.ts +144 -0
- package/src/view/jsx-runtime.ts +233 -0
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Turns unhandled exceptions into HTTP responses. Defines the base
|
|
3
|
+
* `ExceptionHandler` apps subclass, plus the default error-to-response logic
|
|
4
|
+
* that negotiates HTML, JSON, redirects, and the dev stack-trace page.
|
|
5
|
+
*/
|
|
6
|
+
import type { HttpContext } from "../pipeline/HttpContext.ts";
|
|
7
|
+
import type { ZerotalError } from "../errors/ZerotalError.ts";
|
|
8
|
+
import { renderDevErrorPage, renderHttpErrorPage } from "./DevErrorPage.ts";
|
|
9
|
+
import { devSurfacesEnabled } from "../support/env.ts";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Base class for application-level exception handling.
|
|
13
|
+
*
|
|
14
|
+
* Register a subclass with `app.withExceptionHandler(Handler)` in
|
|
15
|
+
* bootstrap/app.ts. The framework calls `report()` then `render()` for
|
|
16
|
+
* every unhandled exception thrown by a route handler or middleware.
|
|
17
|
+
*
|
|
18
|
+
* @example
|
|
19
|
+
* // app/exceptions/Handler.ts
|
|
20
|
+
* import { ExceptionHandler } from '@zerotal/core';
|
|
21
|
+
*
|
|
22
|
+
* export class Handler extends ExceptionHandler {
|
|
23
|
+
* override async render(err: unknown, ctx: HttpContext): Promise<Response> {
|
|
24
|
+
* if (err instanceof ModelNotFoundError) {
|
|
25
|
+
* return Response.json({ message: 'Not found' }, { status: 404 });
|
|
26
|
+
* }
|
|
27
|
+
* return super.render(err, ctx);
|
|
28
|
+
* }
|
|
29
|
+
* }
|
|
30
|
+
*/
|
|
31
|
+
export abstract class ExceptionHandler {
|
|
32
|
+
// Explicit constructor so JSC's function-coverage counter attributes
|
|
33
|
+
// the super() call from subclasses to this entry.
|
|
34
|
+
constructor() {}
|
|
35
|
+
|
|
36
|
+
protected dontReport: Array<new (...args: never[]) => unknown> = [];
|
|
37
|
+
|
|
38
|
+
private static readonly _alwaysSilent = new Set([
|
|
39
|
+
"ValidationRedirectError",
|
|
40
|
+
"ValidationJsonError",
|
|
41
|
+
"ValidationError",
|
|
42
|
+
"PrecognitionResponse",
|
|
43
|
+
"NotFoundError",
|
|
44
|
+
]);
|
|
45
|
+
|
|
46
|
+
protected shouldReport(error: unknown): boolean {
|
|
47
|
+
const name = (error as { name?: string } | null)?.name;
|
|
48
|
+
if (name && ExceptionHandler._alwaysSilent.has(name)) return false;
|
|
49
|
+
for (const Type of this.dontReport) {
|
|
50
|
+
if (error instanceof (Type as unknown as new (...args: never[]) => object)) return false;
|
|
51
|
+
}
|
|
52
|
+
return true;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async report(error: unknown, _ctx?: HttpContext): Promise<void> {
|
|
56
|
+
if (!this.shouldReport(error)) return;
|
|
57
|
+
console.error("[Zerotal] Unhandled error:", error);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async render(error: unknown, ctx: HttpContext): Promise<Response> {
|
|
61
|
+
return ExceptionHandler.defaultRender(error, ctx);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Shared error → Response logic.
|
|
66
|
+
*
|
|
67
|
+
* Response format depends on what the client accepts:
|
|
68
|
+
*
|
|
69
|
+
* Browser (Accept: text/html) → styled HTML page (all error classes)
|
|
70
|
+
* Inertia XHR (X-Inertia: true) → JSON (Inertia shows its own modal)
|
|
71
|
+
* API client (Accept: application/json) → JSON
|
|
72
|
+
*
|
|
73
|
+
* Error-class routing:
|
|
74
|
+
* 1. ValidationRedirectError → 303 redirect (always, any client)
|
|
75
|
+
* 2. ValidationJsonError → 422 JSON (always — the errors object IS the response)
|
|
76
|
+
* 3. ZerotalError 4xx → styled 4xx HTML or JSON depending on client
|
|
77
|
+
* 4. Everything else → dev stack-trace page or minimal 500
|
|
78
|
+
*/
|
|
79
|
+
static async defaultRender(error: unknown, ctx?: HttpContext): Promise<Response> {
|
|
80
|
+
// Fail closed for debug output: the dev stack-trace page needs either a
|
|
81
|
+
// developer-supervised process (the `serve --dev` worker) or an explicitly
|
|
82
|
+
// non-prod APP_ENV. An unset/unknown/`staging` environment is treated as
|
|
83
|
+
// production, so a deploy that forgets to set it never leaks stack traces
|
|
84
|
+
// or source context to clients.
|
|
85
|
+
const isProduction = !devSurfacesEnabled();
|
|
86
|
+
const wantsHtml = _wantsHtml(ctx);
|
|
87
|
+
|
|
88
|
+
// ── Always-redirect: never render as HTML or JSON ─────────────────────
|
|
89
|
+
const name = (error as { name?: string } | null)?.name;
|
|
90
|
+
|
|
91
|
+
if (name === "ValidationRedirectError") {
|
|
92
|
+
const { redirectTo } = error as { redirectTo: string };
|
|
93
|
+
return new Response(null, {
|
|
94
|
+
status: 303,
|
|
95
|
+
headers: { Location: redirectTo },
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// ── Precognition: the error builds its own 204/422 response ───────────────
|
|
100
|
+
if (name === "PrecognitionResponse") {
|
|
101
|
+
return (error as { toResponse(): Response }).toResponse();
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// ── Validation errors: structured JSON (form submits use redirect above) ─
|
|
105
|
+
if (name === "ValidationJsonError") {
|
|
106
|
+
const { errors } = error as { errors: Record<string, string> };
|
|
107
|
+
return Response.json({ message: "Validation failed", errors }, { status: 422 });
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// ── Named HTTP errors (4xx / 5xx ZerotalError subclasses) ────────────────
|
|
111
|
+
if (error instanceof Error && "status" in error) {
|
|
112
|
+
const httpError = error as ZerotalError;
|
|
113
|
+
const status = httpError.status;
|
|
114
|
+
const extraHeaders = (error as { headers?: Record<string, string> }).headers;
|
|
115
|
+
|
|
116
|
+
if (wantsHtml) {
|
|
117
|
+
if (!isProduction && status >= 500) {
|
|
118
|
+
return _withHeaders(renderDevErrorPage(error, ctx), extraHeaders);
|
|
119
|
+
}
|
|
120
|
+
return _withHeaders(
|
|
121
|
+
renderHttpErrorPage(status, httpError.message, httpError.code, ctx),
|
|
122
|
+
extraHeaders,
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return Response.json(
|
|
127
|
+
{ message: httpError.message, code: httpError.code },
|
|
128
|
+
{ status, ...(extraHeaders ? { headers: extraHeaders } : {}) },
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// ── Unknown / unhandled errors (genuine bugs) ─────────────────────────
|
|
133
|
+
if (!isProduction && wantsHtml) {
|
|
134
|
+
return renderDevErrorPage(error, ctx);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (!isProduction) {
|
|
138
|
+
// API client in dev: JSON with message.
|
|
139
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
140
|
+
return Response.json({ message, code: "E_INTERNAL" }, { status: 500 });
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Production: no internals exposed.
|
|
144
|
+
if (wantsHtml) {
|
|
145
|
+
return renderHttpErrorPage(500, "Internal Server Error", undefined, ctx);
|
|
146
|
+
}
|
|
147
|
+
return Response.json({ message: "Internal Server Error" }, { status: 500 });
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Merge extra headers into a (possibly pending) Response and return it. */
|
|
152
|
+
async function _withHeaders(
|
|
153
|
+
res: Response | Promise<Response>,
|
|
154
|
+
headers?: Record<string, string>,
|
|
155
|
+
): Promise<Response> {
|
|
156
|
+
const resolved = await res;
|
|
157
|
+
if (headers) {
|
|
158
|
+
for (const [key, value] of Object.entries(headers)) resolved.headers.set(key, value);
|
|
159
|
+
}
|
|
160
|
+
return resolved;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* True when the client will render an HTML response.
|
|
165
|
+
*
|
|
166
|
+
* Covers both regular browser navigation AND Inertia XHR — Inertia sends
|
|
167
|
+
* `Accept: text/html` and shows non-Inertia HTML responses in its error
|
|
168
|
+
* modal (iframe overlay), which is exactly what we want for the dev page.
|
|
169
|
+
*
|
|
170
|
+
* Returns false only for pure JSON clients (`Accept: application/json`).
|
|
171
|
+
*/
|
|
172
|
+
function _wantsHtml(ctx?: HttpContext): boolean {
|
|
173
|
+
try {
|
|
174
|
+
if (!ctx?.request) return false;
|
|
175
|
+
const accept = ctx.request.headers.get("Accept") ?? "";
|
|
176
|
+
// Pure JSON clients — return JSON.
|
|
177
|
+
if (accept.startsWith("application/json")) return false;
|
|
178
|
+
// Browsers and Inertia XHR both include text/html in Accept.
|
|
179
|
+
return accept.includes("text/html");
|
|
180
|
+
} catch {
|
|
181
|
+
return false;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The single ambient lookup in the framework: `currentApp()`.
|
|
3
|
+
*
|
|
4
|
+
* Everything that "feels global" — facades, `config()`, `Router.get(...)` sugar —
|
|
5
|
+
* is a thin window onto one owned, instance-scoped {@link Application}, reached
|
|
6
|
+
* through exactly one door. There is one process **default** application (set by
|
|
7
|
+
* {@link Application.create}), overridable within an {@link withApp} scope so two
|
|
8
|
+
* apps can coexist in one process (embedded test harnesses, multi-tenant control
|
|
9
|
+
* planes, migrating against a second app's config).
|
|
10
|
+
*
|
|
11
|
+
* The accessor consults the {@link withApp} scope first, then the process
|
|
12
|
+
* default. This is the only ambient app reference in the framework.
|
|
13
|
+
*/
|
|
14
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
15
|
+
import type { Application } from "./Application.ts";
|
|
16
|
+
|
|
17
|
+
/** The process-default application. Set by {@link Application.create}; cleared on reset. */
|
|
18
|
+
let _default: Application | undefined;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Per-scope override. Code running inside {@link withApp} sees that app as the
|
|
22
|
+
* current one, so ambient lookups resolve to it instead of the process default.
|
|
23
|
+
*/
|
|
24
|
+
const _scope = new AsyncLocalStorage<Application>();
|
|
25
|
+
|
|
26
|
+
/** @internal Set (or clear) the process-default application. Called by the application lifecycle. */
|
|
27
|
+
export function setDefaultApp(app: Application | undefined): void {
|
|
28
|
+
_default = app;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** @internal The process-default application, or `undefined` when none has been created. */
|
|
32
|
+
export function defaultApp(): Application | undefined {
|
|
33
|
+
return _default;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The current application — the framework's one ambient accessor.
|
|
38
|
+
*
|
|
39
|
+
* Resolution order: the {@link withApp} scope override, then the process default.
|
|
40
|
+
*
|
|
41
|
+
* @throws {Error} when no application is available (none created and not inside a
|
|
42
|
+
* {@link withApp} scope).
|
|
43
|
+
*/
|
|
44
|
+
export function currentApp(): Application {
|
|
45
|
+
const app = _scope.getStore() ?? _default;
|
|
46
|
+
if (!app) {
|
|
47
|
+
throw new Error(
|
|
48
|
+
"No current application. Call Application.create() first, or run inside withApp(app, …).",
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
return app;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** The current application, or `undefined` when none is available. Safe off-app (CLI bootstrap, tests). */
|
|
55
|
+
export function tryCurrentApp(): Application | undefined {
|
|
56
|
+
return _scope.getStore() ?? _default;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Run `fn` with `app` as the current application for the duration of the call —
|
|
61
|
+
* and everything it `await`s — overriding the process default within this scope.
|
|
62
|
+
* The override is confined to the async context, so concurrent scopes never leak
|
|
63
|
+
* into one another.
|
|
64
|
+
*
|
|
65
|
+
* @example
|
|
66
|
+
* ```ts
|
|
67
|
+
* // Run a migration against a second app's config without disturbing the default.
|
|
68
|
+
* await withApp(secondApp, () => secondApp.container.make("db"));
|
|
69
|
+
* ```
|
|
70
|
+
*/
|
|
71
|
+
export function withApp<T>(app: Application, fn: () => T): T {
|
|
72
|
+
return _scope.run(app, fn);
|
|
73
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Front-end asset URL helper and asset versioning.
|
|
3
|
+
*
|
|
4
|
+
* `asset(path)` builds the public URL for a built asset, honouring the
|
|
5
|
+
* configured `app.assets.prefix`. In dev it appends `?v=<version>` so the
|
|
6
|
+
* browser re-fetches a rebuilt file on the next reload: the entry points named
|
|
7
|
+
* here keep the same filename from one build to the next, so nothing in the URL
|
|
8
|
+
* would otherwise change and the query string has to do the busting. (Bundlers
|
|
9
|
+
* content-hash the code-split *chunks* an entry pulls in, but those URLs live
|
|
10
|
+
* inside the bundle and are never passed through this helper.) In production
|
|
11
|
+
* the clean path is returned unchanged.
|
|
12
|
+
*
|
|
13
|
+
* The version is a per-build token, bumped on every dev rebuild (see
|
|
14
|
+
* {@link bumpAssetVersion}) and propagated from the dev orchestrator to the
|
|
15
|
+
* server worker over its reload channel. This mirrors Inertia's asset-version
|
|
16
|
+
* model — one token per build, busting every asset at once.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
let _prefix = "/";
|
|
20
|
+
let _dev = false;
|
|
21
|
+
let _version = "";
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Configure the asset helper from resolved app config. Called once at boot.
|
|
25
|
+
*
|
|
26
|
+
* @param opts.prefix URL prefix built assets are served under (`app.assets.prefix`).
|
|
27
|
+
* @param opts.dev Whether `?v=` cache-busting should be applied (dev only).
|
|
28
|
+
*/
|
|
29
|
+
export function configureAssets(opts: { prefix?: string; dev?: boolean }): void {
|
|
30
|
+
if (opts.prefix !== undefined) _prefix = opts.prefix;
|
|
31
|
+
if (opts.dev !== undefined) _dev = opts.dev;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Replace the current asset version (e.g. a build token from the orchestrator). */
|
|
35
|
+
export function setAssetVersion(version: string): void {
|
|
36
|
+
_version = version;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** The current asset version, or `""` when unset. */
|
|
40
|
+
export function assetVersion(): string {
|
|
41
|
+
return _version;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Bump the asset version to a fresh token and return it.
|
|
46
|
+
*
|
|
47
|
+
* Called by the dev orchestrator after each successful rebuild so the next
|
|
48
|
+
* `asset()` URL changes and the browser refetches the file.
|
|
49
|
+
*/
|
|
50
|
+
export function bumpAssetVersion(): string {
|
|
51
|
+
_version = Date.now().toString(36);
|
|
52
|
+
return _version;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Build the public URL for a front-end asset.
|
|
57
|
+
*
|
|
58
|
+
* @param path Asset path relative to the asset prefix (leading slash optional).
|
|
59
|
+
* @returns `<prefix>/<path>`, with `?v=<version>` appended in dev.
|
|
60
|
+
*
|
|
61
|
+
* @example
|
|
62
|
+
* asset("/app.css") // prod → "/app.css"
|
|
63
|
+
* asset("app.css") // dev → "/app.css?v=lq3k7m"
|
|
64
|
+
*/
|
|
65
|
+
export function asset(path: string): string {
|
|
66
|
+
const url = _joinUrl(_prefix, path);
|
|
67
|
+
if (_dev && _version) {
|
|
68
|
+
const separator = url.includes("?") ? "&" : "?";
|
|
69
|
+
return `${url}${separator}v=${_version}`;
|
|
70
|
+
}
|
|
71
|
+
return url;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Join a URL prefix and an asset path with exactly one slash between them. */
|
|
75
|
+
function _joinUrl(prefix: string, path: string): string {
|
|
76
|
+
const left = prefix.endsWith("/") ? prefix.slice(0, -1) : prefix;
|
|
77
|
+
const right = path.startsWith("/") ? path : `/${path}`;
|
|
78
|
+
return `${left}${right}` || "/";
|
|
79
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Front-end asset URL helper and asset versioning (the `@zerotal/core/assets`
|
|
3
|
+
* subpath). {@link asset} builds the public URL for a built asset, appending a
|
|
4
|
+
* `?v=<version>` cache-buster in dev; the versioning helpers let the dev
|
|
5
|
+
* orchestrator bust every asset at once after a rebuild.
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* ```ts
|
|
9
|
+
* import { asset } from "@zerotal/core/assets";
|
|
10
|
+
*
|
|
11
|
+
* asset("/app.css"); // prod → "/app.css"; dev → "/app.css?v=lq3k7m"
|
|
12
|
+
* ```
|
|
13
|
+
*
|
|
14
|
+
* @packageDocumentation
|
|
15
|
+
*/
|
|
16
|
+
export { asset, assetVersion, setAssetVersion } from "./assets.ts";
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The authenticated user for the current request.
|
|
3
|
+
* Extend this interface in your app via declaration merging to match your User model.
|
|
4
|
+
*
|
|
5
|
+
* @remarks
|
|
6
|
+
* The root-level alias of the kernel-owned {@link AuthenticatableUser} contract
|
|
7
|
+
* (from `@zerotal/core/contracts`) which it extends, so augmenting either
|
|
8
|
+
* interface resolves to the same shape.
|
|
9
|
+
*
|
|
10
|
+
* @example
|
|
11
|
+
* // app/models/User.ts
|
|
12
|
+
* declare module '@zerotal/core' {
|
|
13
|
+
* interface AuthenticatedUser extends User {}
|
|
14
|
+
* }
|
|
15
|
+
*/
|
|
16
|
+
import type { AuthenticatableUser } from "../contracts/auth.ts";
|
|
17
|
+
|
|
18
|
+
export interface AuthenticatedUser extends AuthenticatableUser {}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lints monorepo packages against the framework's conventions — provider
|
|
3
|
+
* location and metadata, config-factory shape, test presence, packaging, and
|
|
4
|
+
* error-base discipline — producing a structured report of violations.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/** How serious a lint violation is. */
|
|
8
|
+
export type Severity = 'high' | 'medium' | 'low';
|
|
9
|
+
|
|
10
|
+
/** A single rule violation found in a package. */
|
|
11
|
+
export interface Violation { rule: string; message: string; severity: Severity; }
|
|
12
|
+
/** The full set of violations found in one package. */
|
|
13
|
+
export interface PackageReport { package: string; violations: Violation[]; }
|
|
14
|
+
|
|
15
|
+
interface PackageFiles {
|
|
16
|
+
source: Map<string, string>;
|
|
17
|
+
packageJson: Record<string, unknown> | null;
|
|
18
|
+
packageJsonRaw: string | null;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async function _readPackage(absoluteDir: string): Promise<PackageFiles> {
|
|
22
|
+
const source = new Map<string, string>();
|
|
23
|
+
try {
|
|
24
|
+
const glob = new Bun.Glob('src/**/*.{ts,tsx}');
|
|
25
|
+
for await (const relativePath of glob.scan({ cwd: absoluteDir, onlyFiles: true })) {
|
|
26
|
+
try { source.set(relativePath.replace(/\\/g, '/'), await Bun.file(`${absoluteDir}/${relativePath}`).text()); } catch {}
|
|
27
|
+
}
|
|
28
|
+
} catch {}
|
|
29
|
+
let packageJson: Record<string, unknown> | null = null;
|
|
30
|
+
let packageJsonRaw: string | null = null;
|
|
31
|
+
try {
|
|
32
|
+
packageJsonRaw = await Bun.file(`${absoluteDir}/package.json`).text();
|
|
33
|
+
packageJson = JSON.parse(packageJsonRaw) as Record<string, unknown>;
|
|
34
|
+
} catch {}
|
|
35
|
+
return { source, packageJson, packageJsonRaw };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function _isProviderPath(relativePath: string): boolean { return /Provider\.tsx?$/.test(relativePath); }
|
|
39
|
+
|
|
40
|
+
function _checkProviderLocation(files: PackageFiles): Violation[] {
|
|
41
|
+
const providers = [...files.source.keys()].filter(_isProviderPath);
|
|
42
|
+
if (providers.length === 0) return [];
|
|
43
|
+
const violations: Violation[] = [];
|
|
44
|
+
for (const path of providers) if (!path.startsWith('src/provider/')) violations.push({ rule: 'provider-location', severity: 'high', message: `provider '${path}' must live at src/provider/` });
|
|
45
|
+
return violations;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function _checkProviderMetadata(files: PackageFiles): Violation[] {
|
|
49
|
+
const providers = [...files.source.entries()].filter(([path]) => _isProviderPath(path));
|
|
50
|
+
if (providers.length === 0) return [];
|
|
51
|
+
const violations: Violation[] = [];
|
|
52
|
+
for (const [path, content] of providers) {
|
|
53
|
+
const registersBinding = /container\.(singleton|value|bind)\s*\(/.test(content);
|
|
54
|
+
if (registersBinding && !/static\s+(override\s+)?provides\b/.test(content)) violations.push({ rule: 'provider-provides', severity: 'medium', message: `${path}: registers a binding but is missing 'static provides'` });
|
|
55
|
+
if (!/static\s+(override\s+)?environments\b/.test(content)) violations.push({ rule: 'provider-environments', severity: 'medium', message: `${path}: missing 'static environments'` });
|
|
56
|
+
}
|
|
57
|
+
return violations;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function _checkConfigFactory(files: PackageFiles): Violation[] {
|
|
61
|
+
const config = files.source.get('src/config.ts');
|
|
62
|
+
if (!config) return [];
|
|
63
|
+
const violations: Violation[] = [];
|
|
64
|
+
const match =
|
|
65
|
+
config.match(/export\s+function\s+([A-Za-z_]\w*)Config\s*(?:<[^>]*>)?\s*\(([^)]*)\)/) ??
|
|
66
|
+
config.match(/export\s+const\s+([A-Za-z_]\w*)Config\s*=\s*(?:<[^>]*>)?\s*\(([^)]*)\)/);
|
|
67
|
+
if (!match) { violations.push({ rule: 'config-factory', severity: 'medium', message: `src/config.ts: no '<Name>Config(...)' factory found` }); return violations; }
|
|
68
|
+
const [, namePrefix, params] = match;
|
|
69
|
+
const factoryName = `${namePrefix}Config`;
|
|
70
|
+
if (!/^[A-Z]/.test(factoryName)) violations.push({ rule: 'config-casing', severity: 'high', message: `config factory '${factoryName}' must be PascalCase` });
|
|
71
|
+
if (!/Partial\s*</.test(params ?? '')) violations.push({ rule: 'config-partial', severity: 'high', message: `config factory '${factoryName}' parameter must be Partial<...Shape>` });
|
|
72
|
+
return violations;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function _checkConfigDeepMerge(files: PackageFiles): Violation[] {
|
|
76
|
+
const config = files.source.get('src/config.ts');
|
|
77
|
+
if (!config) return [];
|
|
78
|
+
// Only applies when the file actually defines a <Name>Config factory.
|
|
79
|
+
const hasFactory =
|
|
80
|
+
/export\s+function\s+[A-Za-z_]\w*Config\s*(?:<[^>]*>)?\s*\(/.test(config) ||
|
|
81
|
+
/export\s+const\s+[A-Za-z_]\w*Config\s*=/.test(config);
|
|
82
|
+
if (!hasFactory) return [];
|
|
83
|
+
if (!/\bdeepMerge\s*\(/.test(config))
|
|
84
|
+
return [{ rule: 'config-deepmerge', severity: 'medium', message: `src/config.ts: the config factory must merge with deepMerge(defaults, options)` }];
|
|
85
|
+
return [];
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function _checkTests(files: PackageFiles): Violation[] {
|
|
89
|
+
const hasTest = [...files.source.keys()].some((path) => /\.test\.tsx?$/.test(path));
|
|
90
|
+
return hasTest ? [] : [{ rule: 'tests', severity: 'high', message: 'package ships no *.test.ts(x) files' }];
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function _checkPackaging(files: PackageFiles): Violation[] {
|
|
94
|
+
const packageJson = files.packageJson;
|
|
95
|
+
if (!packageJson) return [{ rule: 'package-json', severity: 'high', message: 'missing or invalid package.json' }];
|
|
96
|
+
const violations: Violation[] = [];
|
|
97
|
+
if (packageJson['type'] !== 'module') violations.push({ rule: 'esm', severity: 'medium', message: `package.json "type" must be "module"` });
|
|
98
|
+
if (!packageJson['exports'] && !packageJson['main'] && !packageJson['bin']) violations.push({ rule: 'exports', severity: 'medium', message: 'package.json must define "exports" or "main"' });
|
|
99
|
+
return violations;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function _checkErrorDiscipline(files: PackageFiles, packageName: string): Violation[] {
|
|
103
|
+
const violations: Violation[] = [];
|
|
104
|
+
for (const [path, content] of files.source) {
|
|
105
|
+
if (path.endsWith('.test.ts') || path.endsWith('.test.tsx')) continue;
|
|
106
|
+
if (packageName === '@zerotal/core' && path.endsWith('errors/ZerotalError.ts')) continue;
|
|
107
|
+
// Client-bundle code can't import the server-only ZerotalError; it defines its
|
|
108
|
+
// own native-Error base (e.g. FlowClientError) for the CSP-safe runtime.
|
|
109
|
+
if (/(^|\/)client\//.test(path)) continue;
|
|
110
|
+
if (/\bclass\s+\w+\s+extends\s+Error\b/.test(content)) violations.push({ rule: 'error-base', severity: 'medium', message: `${path}: error classes must extend ZerotalError, not Error` });
|
|
111
|
+
}
|
|
112
|
+
return violations;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Lint every package under `packagesDir` and return a report per package. */
|
|
116
|
+
export async function lintPackages(packagesDir: string): Promise<PackageReport[]> {
|
|
117
|
+
const reports: PackageReport[] = [];
|
|
118
|
+
let entries: string[];
|
|
119
|
+
try {
|
|
120
|
+
const glob = new Bun.Glob('*/package.json');
|
|
121
|
+
entries = [];
|
|
122
|
+
for await (const relativePath of glob.scan({ cwd: packagesDir, onlyFiles: true })) entries.push(relativePath.replace(/\\/g, '/').split('/')[0]!);
|
|
123
|
+
} catch { return reports; }
|
|
124
|
+
entries.sort();
|
|
125
|
+
for (const dir of entries) {
|
|
126
|
+
const absoluteDir = `${packagesDir}/${dir}`;
|
|
127
|
+
const files = await _readPackage(absoluteDir);
|
|
128
|
+
const packageName = (files.packageJson?.['name'] as string | undefined) ?? `@zerotal/${dir}`;
|
|
129
|
+
const violations: Violation[] = [
|
|
130
|
+
..._checkProviderLocation(files),
|
|
131
|
+
..._checkProviderMetadata(files),
|
|
132
|
+
..._checkConfigFactory(files),
|
|
133
|
+
..._checkConfigDeepMerge(files),
|
|
134
|
+
..._checkTests(files),
|
|
135
|
+
..._checkPackaging(files),
|
|
136
|
+
..._checkErrorDiscipline(files, packageName),
|
|
137
|
+
];
|
|
138
|
+
reports.push({ package: packageName, violations });
|
|
139
|
+
}
|
|
140
|
+
return reports;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Total the violation count across all package reports. */
|
|
144
|
+
export function countViolations(reports: PackageReport[]): number {
|
|
145
|
+
return reports.reduce((total, report) => total + report.violations.length, 0);
|
|
146
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generates the source files for a new `@zerotal/*` package — its package.json,
|
|
3
|
+
* config factory, manager, service provider, facade, and a starter test — ready
|
|
4
|
+
* to write to disk.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Normalise a raw package name into its kebab-case `token` (without the
|
|
9
|
+
* `@zerotal/` scope) and its PascalCase form.
|
|
10
|
+
*/
|
|
11
|
+
export function packageNames(rawName: string): { token: string; pascal: string } {
|
|
12
|
+
const token = rawName.trim().toLowerCase().replace(/^@zerotal\//, '').replace(/[^a-z0-9-]+/g, '-').replace(/^-+|-+$/g, '');
|
|
13
|
+
const pascal = token.split('-').filter(Boolean).map((segment) => segment.charAt(0).toUpperCase() + segment.slice(1)).join('');
|
|
14
|
+
return { token, pascal };
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Build the file map (path → contents) for a new package named `rawName`. */
|
|
18
|
+
export function scaffoldPackage(rawName: string): Map<string, string> {
|
|
19
|
+
const { token, pascal } = packageNames(rawName);
|
|
20
|
+
const files = new Map<string, string>();
|
|
21
|
+
|
|
22
|
+
files.set('package.json', JSON.stringify({
|
|
23
|
+
name: `@zerotal/${token}`,
|
|
24
|
+
version: '0.0.1',
|
|
25
|
+
private: false,
|
|
26
|
+
type: 'module',
|
|
27
|
+
main: './src/index.ts',
|
|
28
|
+
types: './src/index.ts',
|
|
29
|
+
exports: { '.': './src/index.ts' },
|
|
30
|
+
scripts: {
|
|
31
|
+
build: 'bun build ./src/index.ts --outdir ./dist --target bun --format esm',
|
|
32
|
+
test: 'bun test',
|
|
33
|
+
typecheck: 'tsc --noEmit',
|
|
34
|
+
},
|
|
35
|
+
dependencies: { '@zerotal/core': 'workspace:*' },
|
|
36
|
+
devDependencies: { typescript: '*' },
|
|
37
|
+
}, null, 2) + '\n');
|
|
38
|
+
|
|
39
|
+
files.set('src/index.ts',
|
|
40
|
+
`// @zerotal/${token} — public API barrel
|
|
41
|
+
export { ${pascal}Provider } from './provider/${pascal}Provider.ts';
|
|
42
|
+
export { ${pascal} } from './facades/${pascal}.ts';
|
|
43
|
+
export { ${pascal}Manager } from './${pascal}Manager.ts';
|
|
44
|
+
export { ${pascal}Config } from './config.ts';
|
|
45
|
+
export type { ${pascal}ConfigShape } from './config.ts';
|
|
46
|
+
`);
|
|
47
|
+
|
|
48
|
+
files.set('src/config.ts',
|
|
49
|
+
`import { deepMerge } from '@zerotal/core';
|
|
50
|
+
|
|
51
|
+
export interface ${pascal}ConfigShape {
|
|
52
|
+
/** Example option — replace with your package's real config. */
|
|
53
|
+
enabled: boolean;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const defaults: ${pascal}ConfigShape = {
|
|
57
|
+
enabled: true,
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
export function ${pascal}Config(options: Partial<${pascal}ConfigShape> = {}): ${pascal}ConfigShape {
|
|
61
|
+
return deepMerge(defaults, options);
|
|
62
|
+
}
|
|
63
|
+
`);
|
|
64
|
+
|
|
65
|
+
files.set(`src/${pascal}Manager.ts`,
|
|
66
|
+
`import type { ${pascal}ConfigShape } from './config.ts';
|
|
67
|
+
|
|
68
|
+
export class ${pascal}Manager {
|
|
69
|
+
constructor(private readonly config: ${pascal}ConfigShape) {}
|
|
70
|
+
|
|
71
|
+
isEnabled(): boolean {
|
|
72
|
+
return this.config.enabled;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
`);
|
|
76
|
+
|
|
77
|
+
files.set(`src/provider/${pascal}Provider.ts`,
|
|
78
|
+
`import { ServiceProvider } from '@zerotal/core';
|
|
79
|
+
import type { AppEnvironment, ConfigManager } from '@zerotal/core';
|
|
80
|
+
import { ${pascal}Manager } from '../${pascal}Manager.ts';
|
|
81
|
+
import { ${pascal}Config, type ${pascal}ConfigShape } from '../config.ts';
|
|
82
|
+
|
|
83
|
+
declare module '@zerotal/core' {
|
|
84
|
+
interface ContainerBindings {
|
|
85
|
+
'${token}': ${pascal}Manager;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export class ${pascal}Provider extends ServiceProvider {
|
|
90
|
+
static override provides = ['${token}'] as const;
|
|
91
|
+
static override environments: AppEnvironment[] = ['web', 'console', 'test', 'repl'];
|
|
92
|
+
|
|
93
|
+
override onRegister(): void {
|
|
94
|
+
this.app.container.singleton('${token}', () => {
|
|
95
|
+
const config = this.app.container.makeSync('config') as ConfigManager;
|
|
96
|
+
const options = config.get<Partial<${pascal}ConfigShape>>('${token}', {});
|
|
97
|
+
return new ${pascal}Manager(${pascal}Config(options));
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
override async onBooted(): Promise<void> {
|
|
102
|
+
await this.app.container.make('${token}');
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
`);
|
|
106
|
+
|
|
107
|
+
files.set(`src/facades/${pascal}.ts`,
|
|
108
|
+
`import { createFacade } from '@zerotal/core';
|
|
109
|
+
|
|
110
|
+
export const ${pascal} = createFacade('${token}');
|
|
111
|
+
`);
|
|
112
|
+
|
|
113
|
+
files.set(`src/${pascal}.test.ts`,
|
|
114
|
+
`import { describe, it, expect } from 'bun:test';
|
|
115
|
+
import { ${pascal}Manager } from './${pascal}Manager.ts';
|
|
116
|
+
import { ${pascal}Config } from './config.ts';
|
|
117
|
+
|
|
118
|
+
describe('${pascal}Manager', () => {
|
|
119
|
+
it('reflects its config', () => {
|
|
120
|
+
expect(new ${pascal}Manager(${pascal}Config()).isEnabled()).toBe(true);
|
|
121
|
+
expect(new ${pascal}Manager(${pascal}Config({ enabled: false })).isEnabled()).toBe(false);
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
`);
|
|
125
|
+
|
|
126
|
+
return files;
|
|
127
|
+
}
|