@dokebi/cli 0.4.1 → 0.4.26
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/AI.md +262 -57
- package/README.md +35 -10
- package/catalog.json +2519 -257
- package/describe.schema.json +748 -0
- package/dist/api-app.d.ts +2 -0
- package/dist/api-app.js +35 -0
- package/dist/app-urls.js +3 -2
- package/dist/bin.js +527 -35
- package/dist/capability-guide.d.ts +30 -0
- package/dist/capability-guide.js +122 -0
- package/dist/catalog.d.ts +16 -2
- package/dist/catalog.js +19 -7
- package/dist/check/asset-rules.d.ts +6 -0
- package/dist/check/asset-rules.js +163 -0
- package/dist/check/browser-sources.d.ts +20 -0
- package/dist/check/browser-sources.js +89 -0
- package/dist/check/console-rules.d.ts +6 -0
- package/dist/check/console-rules.js +40 -0
- package/dist/check/error-rules.d.ts +21 -0
- package/dist/check/error-rules.js +212 -0
- package/dist/check/form-rules.d.ts +12 -0
- package/dist/check/form-rules.js +69 -6
- package/dist/check/formatter-rules.d.ts +2 -0
- package/dist/check/formatter-rules.js +71 -0
- package/dist/check/i18n-rules.d.ts +27 -0
- package/dist/check/i18n-rules.js +353 -0
- package/dist/check/index.d.ts +15 -1
- package/dist/check/index.js +89 -11
- package/dist/check/mail-rules.d.ts +4 -0
- package/dist/check/mail-rules.js +72 -0
- package/dist/check/module-rules.d.ts +13 -0
- package/dist/check/module-rules.js +104 -0
- package/dist/check/packages.d.ts +8 -0
- package/dist/check/packages.js +16 -0
- package/dist/check/pool-rules.d.ts +8 -0
- package/dist/check/pool-rules.js +164 -0
- package/dist/check/problems.d.ts +6 -1
- package/dist/check/problems.js +100 -2
- package/dist/check/project-rules.d.ts +23 -3
- package/dist/check/project-rules.js +265 -23
- package/dist/check/seal-rules.d.ts +6 -0
- package/dist/check/seal-rules.js +68 -0
- package/dist/check/security-rules.d.ts +10 -0
- package/dist/check/security-rules.js +450 -0
- package/dist/check/shared-state-rules.d.ts +2 -0
- package/dist/check/shared-state-rules.js +114 -0
- package/dist/check/source-rules.d.ts +26 -3
- package/dist/check/source-rules.js +249 -28
- package/dist/check/structure-rules.d.ts +23 -0
- package/dist/check/structure-rules.js +502 -0
- package/dist/check/timer-rules.d.ts +7 -0
- package/dist/check/timer-rules.js +543 -0
- package/dist/check/typescript.d.ts +35 -5
- package/dist/check/typescript.js +163 -17
- package/dist/check/vite-rules.d.ts +2 -0
- package/dist/check/vite-rules.js +136 -0
- package/dist/check/wait-rules.d.ts +16 -0
- package/dist/check/wait-rules.js +337 -0
- package/dist/commands.d.ts +7 -0
- package/dist/commands.js +307 -31
- package/dist/config-diff.d.ts +42 -0
- package/dist/config-diff.js +289 -0
- package/dist/config-edit.d.ts +35 -2
- package/dist/config-edit.js +145 -13
- package/dist/config-init.d.ts +15 -0
- package/dist/config-init.js +61 -0
- package/dist/config-paths.d.ts +22 -0
- package/dist/config-paths.js +82 -0
- package/dist/config-show.d.ts +82 -0
- package/dist/config-show.js +563 -0
- package/dist/config-values.d.ts +8 -0
- package/dist/config-values.js +68 -0
- package/dist/configuration-scaffold.d.ts +42 -0
- package/dist/configuration-scaffold.js +258 -4
- package/dist/configuration.d.ts +63 -3
- package/dist/configuration.js +178 -4
- package/dist/deployment.d.ts +2 -0
- package/dist/deployment.js +111 -0
- package/dist/describe-db.d.ts +22 -0
- package/dist/describe-db.js +176 -0
- package/dist/describe.d.ts +92 -0
- package/dist/describe.js +417 -0
- package/dist/dev-compose.d.ts +20 -0
- package/dist/dev-compose.js +154 -0
- package/dist/dev-guard.js +44 -0
- package/dist/dev-host.d.ts +13 -0
- package/dist/dev-host.js +55 -0
- package/dist/dev-infra.d.ts +72 -4
- package/dist/dev-infra.js +237 -17
- package/dist/dev-watch.d.ts +34 -0
- package/dist/dev-watch.js +192 -0
- package/dist/diagnostics.d.ts +10 -7
- package/dist/diagnostics.js +22 -4
- package/dist/drift.d.ts +50 -0
- package/dist/drift.js +218 -0
- package/dist/env-file.d.ts +25 -1
- package/dist/env-file.js +64 -6
- package/dist/errors.d.ts +7 -1
- package/dist/example-config.d.ts +2 -0
- package/dist/example-config.js +3 -0
- package/dist/first-admin.d.ts +43 -0
- package/dist/first-admin.js +175 -0
- package/dist/formatter-ignore.d.ts +37 -0
- package/dist/formatter-ignore.js +344 -0
- package/dist/generator.d.ts +45 -3
- package/dist/generator.js +205 -15
- package/dist/guide.d.ts +1 -1
- package/dist/guide.js +42 -21
- package/dist/install-module.d.ts +29 -3
- package/dist/install-module.js +142 -13
- package/dist/install-plan.js +5 -3
- package/dist/layout.d.ts +12 -1
- package/dist/layout.js +19 -6
- package/dist/log-writer-control.d.ts +2 -0
- package/dist/log-writer-control.js +106 -27
- package/dist/log-writer.js +43 -10
- package/dist/logging-config.d.ts +8 -3
- package/dist/logging-config.js +19 -5
- package/dist/manifest.d.ts +29 -1
- package/dist/manifest.js +84 -6
- package/dist/merge-install-defaults.d.ts +0 -1
- package/dist/merge-install-defaults.js +13 -47
- package/dist/npm-client.d.ts +59 -4
- package/dist/npm-client.js +220 -16
- package/dist/npm-paths.js +18 -4
- package/dist/openapi.d.ts +27 -0
- package/dist/openapi.js +649 -0
- package/dist/option-fixes.d.ts +39 -0
- package/dist/option-fixes.js +130 -0
- package/dist/output.d.ts +1 -1
- package/dist/output.js +5 -5
- package/dist/plugin-lifecycle.d.ts +34 -2
- package/dist/plugin-lifecycle.js +206 -20
- package/dist/plugin-options.d.ts +19 -4
- package/dist/plugin-options.js +138 -6
- package/dist/plugins-command.js +44 -7
- package/dist/process-group.d.ts +39 -0
- package/dist/process-group.js +132 -0
- package/dist/project-guide.d.ts +19 -1
- package/dist/project-guide.js +119 -20
- package/dist/project-inspector.d.ts +15 -0
- package/dist/project-inspector.js +481 -33
- package/dist/project-state.d.ts +5 -0
- package/dist/project-state.js +8 -0
- package/dist/project.d.ts +203 -19
- package/dist/project.js +3016 -336
- package/dist/recipe-facts.d.ts +127 -0
- package/dist/recipe-facts.js +563 -0
- package/dist/recipe-login.d.ts +63 -0
- package/dist/recipe-login.js +464 -0
- package/dist/recipe-mail.d.ts +21 -0
- package/dist/recipe-mail.js +214 -0
- package/dist/recipe-members.d.ts +35 -0
- package/dist/recipe-members.js +178 -0
- package/dist/recipe-record.d.ts +82 -7
- package/dist/recipe-record.js +226 -22
- package/dist/recipe-reset.d.ts +2 -0
- package/dist/recipe-reset.js +117 -0
- package/dist/recipe-site.d.ts +48 -0
- package/dist/recipe-site.js +396 -0
- package/dist/recipe-step.d.ts +42 -0
- package/dist/recipe-step.js +89 -0
- package/dist/recipe.d.ts +56 -20
- package/dist/recipe.js +703 -131
- package/dist/registry.d.ts +98 -2
- package/dist/registry.js +171 -5
- package/dist/reloadable.d.ts +35 -0
- package/dist/reloadable.js +257 -0
- package/dist/server.d.ts +50 -4
- package/dist/server.js +790 -103
- package/dist/settings.d.ts +48 -0
- package/dist/settings.js +168 -0
- package/dist/site-auth.d.ts +39 -0
- package/dist/site-auth.js +195 -0
- package/dist/template-fixes.d.ts +26 -0
- package/dist/template-fixes.js +285 -0
- package/dist/test-reporter.d.ts +49 -0
- package/dist/test-reporter.js +190 -0
- package/dist/transaction.d.ts +67 -0
- package/dist/transaction.js +428 -46
- package/dist/verify-tests.d.ts +98 -0
- package/dist/verify-tests.js +449 -0
- package/dist/verify.d.ts +113 -4
- package/dist/verify.js +326 -28
- package/dist/view-choice.d.ts +58 -0
- package/dist/view-choice.js +131 -0
- package/dist/vite-watch.js +4 -2
- package/package.json +8 -6
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { Catalog } from './catalog.js';
|
|
2
|
+
export declare const CAPABILITIES_START = "<!-- dokebi:capabilities:start -->";
|
|
3
|
+
export declare const CAPABILITIES_END = "<!-- dokebi:capabilities:end -->";
|
|
4
|
+
/**
|
|
5
|
+
* D-0095, word for word in the recipe's needs-answers `next`, the guide, AGENTS.md and AI.md: who
|
|
6
|
+
* made each choice is recorded per answer, so the agent never reports its own choice as the user's.
|
|
7
|
+
*/
|
|
8
|
+
export declare const ANSWER_SOURCES = "--answers-from user only for choices the user or the request explicitly made; anything the AI decided goes in --ai-chose. An inference is your choice too: \"admin login because the request mentions admins\" or \"no mail because sign-up skips confirmation\" goes in --ai-chose with that reason; only what the request states in words (PostgreSQL, EJS, Docker) is the user's. If the user said to decide some or all of the choices: pass your choices as flags and name them in --ai-chose <id,\u2026> (give each a one-line --ai-reason <id>=<why>), or add --defaults to take the defaults for the questions you leave out; with --defaults alone (the user left every choice to you) every flag you pass is your choice.";
|
|
9
|
+
/**
|
|
10
|
+
* D-0065 §11, word for word: the first sentence of the recipe's needs-answers `next` (and of the
|
|
11
|
+
* fix of ANSWERS_SOURCE_REQUIRED), step 3 of the guide and AGENTS.md §1 (there with the flags in
|
|
12
|
+
* inline code, `markdownFlags`). AI.md and docs/cli quote it; their tests compare the copies.
|
|
13
|
+
*/
|
|
14
|
+
export declare const ASK_AND_WAIT = "Ask these questions and end your turn; do not run recipe/add/config until the user answers. Only use --defaults if the user said to decide.";
|
|
15
|
+
/**
|
|
16
|
+
* A shared sentence as Markdown writes it: every CLI flag (`--defaults`) in inline code. The
|
|
17
|
+
* guide prints the sentences as plain text, AGENTS.md and AI.md as Markdown (D-0110 §5).
|
|
18
|
+
*/
|
|
19
|
+
export declare function markdownFlags(sentence: string): string;
|
|
20
|
+
/**
|
|
21
|
+
* D-0098, word for word in the guide, AGENTS.md, AI.md and the integration rules: a service people
|
|
22
|
+
* use in a browser is a browser app with screens; the view defaults to Inertia + Vue, and EJS or
|
|
23
|
+
* no screens is a product choice that is recorded and reported.
|
|
24
|
+
*/
|
|
25
|
+
export declare const BROWSER_APPS = "When people use the service through a browser (they sign up, browse, press buttons, read lists; whatever language the request is written in), it is a browser app: build its screens. The view is a product choice whose default is Inertia + Vue: the site and blog recipes ask it (--view), and outside a recipe npx dokebi app add <name> --inertia makes a Vue app. EJS, or no screens at all (API only), changes the product: it is the user's choice, or yours only when the user left it to you; then record it (--ai-chose view with an --ai-reason view=<why>; with the api recipe the view is none) and say it in the first lines of your final message. The api recipe builds no browser screens: it is for clients that are other programs (an API the user asked for), and it records view = none with who chose it.";
|
|
26
|
+
export declare const INTEGRATION_RULES: readonly string[];
|
|
27
|
+
/** Catalog metadata is the single source of the available package descriptions. */
|
|
28
|
+
export declare function capabilityBlock(catalog: Catalog): string;
|
|
29
|
+
/** Never overwrite custom prose or a project/installed block with malformed boundaries. */
|
|
30
|
+
export declare function syncCapabilityBlock(text: string, block: string): string | undefined;
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
export const CAPABILITIES_START = '<!-- dokebi:capabilities:start -->';
|
|
2
|
+
export const CAPABILITIES_END = '<!-- dokebi:capabilities:end -->';
|
|
3
|
+
/**
|
|
4
|
+
* D-0095, word for word in the recipe's needs-answers `next`, the guide, AGENTS.md and AI.md: who
|
|
5
|
+
* made each choice is recorded per answer, so the agent never reports its own choice as the user's.
|
|
6
|
+
*/
|
|
7
|
+
export const ANSWER_SOURCES = '--answers-from user only for choices the user or the request explicitly made; anything the AI decided goes in --ai-chose. An inference is your choice too: "admin login because the request mentions admins" or "no mail because sign-up skips confirmation" goes in --ai-chose with that reason; only what the request states in words (PostgreSQL, EJS, Docker) is the user\'s. If the user said to decide some or all of the choices: pass your choices as flags and name them in --ai-chose <id,…> (give each a one-line --ai-reason <id>=<why>), or add --defaults to take the defaults for the questions you leave out; with --defaults alone (the user left every choice to you) every flag you pass is your choice.';
|
|
8
|
+
/**
|
|
9
|
+
* D-0065 §11, word for word: the first sentence of the recipe's needs-answers `next` (and of the
|
|
10
|
+
* fix of ANSWERS_SOURCE_REQUIRED), step 3 of the guide and AGENTS.md §1 (there with the flags in
|
|
11
|
+
* inline code, `markdownFlags`). AI.md and docs/cli quote it; their tests compare the copies.
|
|
12
|
+
*/
|
|
13
|
+
export const ASK_AND_WAIT = 'Ask these questions and end your turn; do not run recipe/add/config until the user answers. Only use --defaults if the user said to decide.';
|
|
14
|
+
/**
|
|
15
|
+
* A shared sentence as Markdown writes it: every CLI flag (`--defaults`) in inline code. The
|
|
16
|
+
* guide prints the sentences as plain text, AGENTS.md and AI.md as Markdown (D-0110 §5).
|
|
17
|
+
*/
|
|
18
|
+
export function markdownFlags(sentence) {
|
|
19
|
+
return sentence.replace(/(?<![`\w-])--[a-z][a-z-]*(?![`\w-])/g, (flag) => `\`${flag}\``);
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* D-0098, word for word in the guide, AGENTS.md, AI.md and the integration rules: a service people
|
|
23
|
+
* use in a browser is a browser app with screens; the view defaults to Inertia + Vue, and EJS or
|
|
24
|
+
* no screens is a product choice that is recorded and reported.
|
|
25
|
+
*/
|
|
26
|
+
export const BROWSER_APPS = "When people use the service through a browser (they sign up, browse, press buttons, read lists; whatever language the request is written in), it is a browser app: build its screens. The view is a product choice whose default is Inertia + Vue: the site and blog recipes ask it (--view), and outside a recipe npx dokebi app add <name> --inertia makes a Vue app. EJS, or no screens at all (API only), changes the product: it is the user's choice, or yours only when the user left it to you; then record it (--ai-chose view with an --ai-reason view=<why>; with the api recipe the view is none) and say it in the first lines of your final message. The api recipe builds no browser screens: it is for clients that are other programs (an API the user asked for), and it records view = none with who chose it.";
|
|
27
|
+
export const INTEGRATION_RULES = [
|
|
28
|
+
'Scope: these are reusable engineering rules, not a product specification. Seal browser transport and Korean/English UI are explicit consumer conventions; other features remain conditional. Examples do not fix domain names, schemas, UI layout, worker count, retention duration, notification recipients or message-history policy for a new product. Apply feature-specific rules only when that feature is requested or installed; do not add uploads, hierarchies, notifications or other product features merely because they appear here. Keep domain entities, user roles, recipient rules and workflows in the project-specific requirements outside this maintained block.',
|
|
29
|
+
'',
|
|
30
|
+
'- Product planning before implementation: treat the result as a real service, not a demo. Map the intended user journeys and distinguish domain concepts before choosing tables, endpoints or screens. Define each domain concept by its purpose, ownership, lifecycle and discovery/editing flow; concepts sharing a persistence mechanism need not share a user flow. For hierarchical features, specify traversal order, sibling order and page boundaries before choosing hierarchy queries. A requested tree listing renders descendants beneath their parent in tree order; a globally time-sorted list with depth badges or parent links is not that tree. Retain parent context when a bounded page continues a branch, and test the actual rendered row order with multiple branches and interleaved creation times. Record acceptance checks for discovery/navigation, creation, editing, cancellation, deletion, permissions, empty/loading/error states and operator recovery. Validate those journeys in the actual UI (see Browser apps and the view: a service people use in a browser has screens); API tests and startup success cannot establish usable product behavior. Resolve material product ambiguity with the user, preserve existing requirements, and keep deployment claims limited to environments actually verified.',
|
|
31
|
+
"- Required browser transport (consumer convention; the core plugin remains optional): install @dokebi/seal in every browser-facing web app whose pages send data, and configure and verify seal-enabled domains. Which hosts: Seal a host whose pages send data (forms, sealedFetch, Inertia visits). A host that only shows public pages (no writes, no signed-in JSON) gains nothing from seal: leave its domain without `seal: true`. Its pages then need no seal files and no 'wasm-unsafe-eval', and may use Inertia SSR. If you do seal such a host, its read-only pages load only the two small modules (about 19 KB), never the wasm. Inertia + Vue is the default view unless the user chose another. Inertia: navigation/forms retain the official sealInertia(http) integration before createInertiaApp (the generated views/app.js imports @dokebi/seal/http when the first page is sealed, then the data-page and every visit, form post and validation error go sealed; a plain JSON body gets 415 SEAL_REQUIRED). The generated vite.config.ts has dokebiInertia() from @dokebi/template-inertia/vite: every import of @dokebi/seal/http in the app's browser code loads the module the app serves (/dokebi/seal/http.mjs), in the build and in dokebi dev, so Vue pages write a plain static import { sealedFetch } from '@dokebi/seal/http' and share the bootstrap's seal; keep that plugin in a vite config of your own (app add warns VITE_PLUGIN_MISSING without it). The plugin also limits what dokebi dev serves to the browser (D-0129): the views folders, node_modules and the files they import; dokebi.yaml, .env, keys and the server code answer 403. Never add server.fs to vite.config (strict: false, a deny list, allow: ['.']) to make a file load: put browser code under views/ or add its folder with dokebiInertia({ allow: ['src/shared/browser'] }) (dokebi check warns vite-dev-files). EJS: write <%- data.seal?.head ?? '' %> in the shared layout <head> (the template-ejs layout has it), and in the <head> of every page rendered without that layout (it is empty on a domain without seal); when a layout has its own import map, the seal head comes first, before that map and every module script. It loads the browser module @dokebi/seal/forms (served as /dokebi/seal/forms.mjs), which seals every same-origin <form method=\"post\"> with no per-form code and sends it with the request header x-dokebi-form: 1, so the form keeps its <%- data.csrf.field %> and the handlers, validation and 303/422 form flow stay those of a plain HTML form. Sealed forms are form bodies: set dokebi-web.formBody: true (without it they get 415 FORM_BODY_DISABLED). A form with a chosen file goes as multipart (file bytes are not sealed) and the forms module moves its _csrf field into the x-csrf-token header itself; write no upload-specific CSRF code. To submit from a script call form.requestSubmit() or submitSealedForm(form) from @dokebi/seal/forms, never form.submit(): it fires no submit event and goes out plain (415). Page navigation (GET) stays plain HTML. Mark a form data-seal=\"off\" only for a deliberate exception, never to make a plain form post pass: a sealed domain refuses that unsealed post (415 SEAL_REQUIRED). Never turn sealing off, set seal: false on a form route or drop the layout head to make a plain form work. Both views: use sealedFetch from @dokebi/seal/http for all application-owned HTTP requests, including REST/JSON and multipart uploads. Do not call raw fetch, Axios or Inertia http.getClient().request from application API adapters. Import bootstrap and request helpers from the same module instance, the served /dokebi/seal/http.mjs: EJS pages reach it through the import map of the seal head, Inertia apps through dokebiInertia(), so both import @dokebi/seal/http by name; never load /dokebi/seal/http.mjs by URL next to a bundled copy of @dokebi/seal/http. Preserve generated template selection and hydration contracts. Server rendering cannot be sealed: SSR writes the page props into the first HTML as plain text, so a seal host refuses to start an SSR app (SEAL_SSR_CONFLICT; dokebi app add refuses --ssr with --seal, and the recipes leave SSR hosts unsealed and say so). If requested SSR conflicts with sealing on a host whose pages send data, resolve the choice with the user rather than silently dropping either requirement (a read-only public host has no conflict: leave it unsealed with SSR). File multipart bytes and GET queries are outside sealing; verify sealed JSON responses and errors too. sealedFetch does not add CSRF headers automatically: follow the installed @dokebi/csrf guide in one shared adapter, read the current token for each request, keep credentials same-origin, and never disable CSRF to make uploads pass.",
|
|
32
|
+
`- Recipe answers (D-0095): a request often settles some choices (for example PostgreSQL, Docker, EJS) and leaves others open. Choices the request leaves open are still the user's: ask and end your turn, unless the user said to decide them. ${ANSWER_SOURCES} The recipe records who made each choice (answeredBy user, ai, default) and verify's tellUser tells the user; never present a choice you made as the user's answer.`,
|
|
33
|
+
`- Browser apps and the view (D-0098): ${BROWSER_APPS} Outside a recipe, npx dokebi app add records the view with --answers-from user or --ai-chose view --ai-reason view=<why>; an EJS app added without either (also the first app of add @dokebi/app) is reported to the user as not the default view with its chooser unrecorded. Validate the journeys of the screens in the actual UI (Product planning).`,
|
|
34
|
+
'- Processes and shared tools: stop a process by the PID you recorded or with npx dokebi stop, never by a pattern (pkill -f, killall): other apps on the machine run the same command lines (src/main.ts, the cluster worker). Check screens in a browser you start yourself with its own temporary profile (a headless or separate Playwright browser), never in the browser the person is using, through an extension or a computer-use tool: its tabs, cookies and signed-in sessions belong to them. Close only the tabs and contexts you opened; never navigate, reuse or close tabs you did not open. Keep downloaded browsers and package caches outside the project folder.',
|
|
35
|
+
'- Self-contained tests: tests start their own app and data (a test configuration, a free port, their own accounts and database or schema) and never point at the running app, its port, its accounts or its database, so running them in any copy never writes to a live app. The same holds for fakes of outside services (a mock webhook receiver, a mail catcher, a stub API): the test starts each on a free port it asks the system for at run time (listen on port 0), never a fixed port from the config that a dev copy may already hold, and stops it when it ends, so the suite passes in a fresh copy and next to a running dev setup. A test that ends database connections on purpose (a lost connection, a killed session) ends only the ones it opened, found by its own application_name or backend id, never every idle or idle-in-transaction session of the server: the app under test has its own connections there (a background loop), and killing them makes the suite fail now and then. A test that starts containers gives them a Docker project name of its own, unique per run (for example the app name plus the process id), never the project name of the dev stack (dokebi.yaml, compose.yaml or COMPOSE_PROJECT_NAME), and removes them when it ends: two runs side by side, or a run next to the dev stack, must not reuse, restart or remove the containers of the other. Tests also never read the app database or port from dokebi.yaml: a test configuration of their own. Before reporting done, stop every helper you started by hand and run the suite again: a test that passes only while a helper of yours runs is a failing test.',
|
|
36
|
+
'- Job queues in tests (D-0139): a test that runs real @dokebi/job jobs creates its own queue with createTestQueue({ connection, after }) from @dokebi/job/testing (after from node:test, or (fn) => t.after(fn)) and puts queue.options under options.dokebi-job.queues of its test application; the helper gives the streams and the consumer unique names and removes them when the test ends, so a shared or measurement NATS server never keeps streams a test made. Never provision fixed stream names in tests and never leave a test stream for later. Remove leftovers of an older run with npx dokebi-job-provision --queue <name> --delete --prefix <namespace> (first with --dry-run): it removes only a queue declared in that dokebi.yaml whose names start with the prefix and that the server holds exactly as declared.',
|
|
37
|
+
'- Background work (D-0141): deliveries (webhooks, mail, notifications), exports, imports, copies, grace periods and cleanups are work items of @dokebi/outbox, not loops you write. Enqueue with outbox.enqueue(tx, { type, owner, destination, payload, dedupeKey, notBefore }) inside the business transaction and register outbox.handle(type, handler, { onDone }) in the setup of the module that owns the work; its runner claims fair batches across owners through one due-time index, retries with backoff, waits out a destination that is down (a breaker per destination, dead only after a horizon of days), keeps dead items for outbox.resend and removes finished rows after a retention. It derives its own small pool of the main connection (pool.max, default 4), so add nothing but run the migration that npx dokebi add @dokebi/outbox writes, keep handlers idempotent with item.key, query in handlers through context.db (the outbox handler pool, never the main pool: an owner whose handlers wait on a locked row then holds at most runner.maxInFlightPerOwner of them while the other owners keep flowing), and put the arrival and drain per second of npx dokebi-outbox stats in the section 6 table of the final report. Never hand-write a claim loop, a retry or dead-letter table or a timer for such work. A destination is one endpoint (one webhook id or URL, one mailbox provider), never a whole host or a shared name such as database: items of one failing endpoint must not pause the others. Database-only cleanup has no destination, and a batch handler records a failing row and skips it rather than failing the whole item. Best-effort signals after a commit (an SSE change notice, a cache invalidation) are not outbox work: send them directly.',
|
|
38
|
+
'- Recipe fit: pick the recipe whose scaffold matches the app (npx dokebi recipe list shows each recipe and its match); when you build on a scaffold made for something else, rename or remove its sample names (tables, option keys, routes, view titles) so none of them remains in the finished app.',
|
|
39
|
+
'- Responsibility boundaries: use Controller -> Service -> Model for application data. Controllers translate HTTP input/output and call services; models own SQL, services provide data and coordinate workflows, and jobs invoke those services. Put app-specific services in src/apps/<app>/services, cross-app services in src/shared/services, and feature-module services in src/shared/modules/<module>/services. Shared services must not import app-specific code. Services accept explicit inputs and return DTOs, never request/reply or template objects; do not expose models or SQL pools through a service for controllers/routes to bypass it. Compose dependencies once during setup. Periodic and background work runs only through the scheduler and job plugins, registered by the module that owns it; never start timers, intervals or background loops in controllers, services or setup code. Keep auth extension wiring separate from reusable member persistence. Audit the final route and registration graph after replacing scaffold flows; remove obsolete unreferenced implementations that duplicate the active behavior, while preserving required engine entries, mail templates and user-owned resources. Split by responsibility, not by arbitrary file counts. When a browser UI is present, keep views/runtime for browser integration and requested reusable editor/upload UI in the template components, and display helpers separate from transport. Use explicit DTO types through services, browser adapters and Vue props; a default any response or any page state defeats SFC checking. Keep readable formatting. Keep original TS/JS/Vue source formatted; minification belongs only to generated build output. Declare a formatter that supports the source formats, include its check in project verification, and inspect the final source rather than claiming readability from build success. Include Vue SFC scripts/templates in type checking; a successful Vite build or server-only tsc is not a Vue type check. dokebi app add --inertia prepares it: npm run typecheck runs tsc and then vue-tsc (typecheck-vue.mjs with tsconfig.vue.json), so keep that step, write pages as <script setup lang="ts">, and type the shared page props in src/infra/inertia/page.d.ts. Preserve the shared error catalog at src/shared/errors.ts.',
|
|
40
|
+
'- Files and folders (maintained layout; refreshed by guide --sync like every rule here): src holds only main.ts and dokebi.generated.ts at its root. One app per src/apps/<app>: app.ts, routes.ts (or routes/ for route groups), controllers/, services/, models/, validation/ and views/<template>/ (Inertia pages in views/<template>/Pages, components in components/, browser integration in views/runtime/). Shared code: src/shared/models, src/shared/services, src/shared/views (layouts, partials), src/shared/locales/<locale>/<namespace>.json, src/shared/mail/<template>/<locale>/ and the error catalog src/shared/errors.ts. One feature per module in src/shared/modules/<module> (module.ts, routes.ts and the same controllers/services/models/validation/views folders; create it with npx dokebi module add). Plugin connection and extension code in src/infra/<name> (src/infra/db/connections.ts, src/infra/auth/extension.ts); your own plugins in src/plugins/<name>. Migrations in src/infra/db/migrations/<connection> and seeds in src/infra/db/seeds/<connection> (npx dokebi-migrate make writes the file). Job handlers and event subscriptions are registered in the setup of the module that owns the feature (src/shared/modules/<module>/module.ts) and call its services; schedules are entries of options.dokebi-scheduler.schedules in dokebi.yaml whose job type such a handler takes; CPU tasks go in src/infra/worker/tasks.ts, event payload types in src/infra/events/types.ts and the cluster worker factory in src/infra/runtime/application.ts. Input validation (route schemas and validators) lives in validation/ of the app or module that owns the route; a DTO type is exported by the service that returns it (cross-app DTOs with their service in src/shared/services). Tests: tests/unit (no external service), tests/integration (a real database, HTTP or broker) and tests/support (helpers and fixtures, never named *.test.ts); npm test runs node --test "tests/**/*.test.ts", which covers both test folders, and tsconfig.json includes tests/**/*.ts. Development tools (imports, measurements, one-off maintenance) go in scripts/ at the project root, run with node scripts/<name>.ts and never imported by src. Name .ts files and folders in kebab-case (Vue components and Inertia pages are PascalCase, as Pages/Posts/Show.vue), and give each file one responsibility.',
|
|
41
|
+
'- Apps split by host (D-0132): with several @dokebi/app apps on different hosts, routes that plugins add answer on every host unless they are bound to apps. Bind each plugin endpoint to its app in dokebi.yaml: dokebi-mcp.app: mcp (the MCP route), dokebi-oauth.app: studio (the authorization server: metadata, authorize with the consent page, token, revoke, register; the issuer origin is a domain of that app) and dokebi-oauth.resources.<id>.app: mcp (the protected resource metadata, on the app of the resource uri host). Every other host then answers the 404 of an unknown route. Put the sign-in page on the issuer app. A route of your own module or plugin on web takes app: <name> or app: [<name>, ...] the same way. Never write an onRequest hook that compares the Host header to hide an endpoint, and never answer 403 on the wrong host. Without @dokebi/app a bound route fails the start (ROUTE_APP_UNAVAILABLE), an unknown app name too (ROUTE_APP_UNKNOWN), and an issuer or resource uri on a domain of another app fails it with INVALID_OPTIONS naming the option to set; health routes stay on every host.',
|
|
42
|
+
'- Module contributions (D-0130): a feature module that offers values to a plugin point (MCP tools, prompts and resources of @dokebi/mcp, mail template folders) declares the points in defineModule({ contributes: [mcpTools] }) and calls module.contribute(point, value) in its own setup, after it builds its services; the contributed objects (an McpTool subclass) take those services in their constructors. Core sets the module up before the plugin that collects the point, so the services exist when the collector reads them, and contributions close when every setup has finished. A module never collects. Never rebuild module services in a separate definePlugin, and never register an app plugin by hand in src/main.ts or src/infra/runtime/application.ts to reach them: describe, the tests and the other entry do not see such a plugin, and doctor and check do not read it. A second module reaches the services through requires: [workspace] and module.get(workspace). Values that no registered plugin collects are dropped with warn POINT_NOT_COLLECTED, npx dokebi check reports module-point-not-installed when the plugin package of a point is not installed, and npx dokebi describe lists each module point and its collector. MCP tool names are unique across modules and plugins (INVALID_TOOL names both owners).',
|
|
43
|
+
"- What `npx dokebi check` enforces from these rules (D-0105), so a broken one fails before you report: background-timer (an error, so verify fails) is a setInterval, a setTimeout/setImmediate that reschedules itself, or a loop that sleeps inside a task started in the background (an immediately invoked async function or a function called without await, such as a polling loop in a module start), in server code under src/ (views and tests are not read): move the work to a @dokebi/job handler registered in the owning module's setup and an entry of options.dokebi-scheduler.schedules; one-shot delays, time limits, awaited sleeps of node:timers/promises and timers of one request or socket are fine (a timer a request stores on this or in an outer variable outlives it and is reported). polling-loop (an error no marker silences) is a loop that sleeps inside a task started in the background, or a timer whose callback does database work: move it to an @dokebi/outbox or @dokebi/job handler. private-pool (an error) is a database pool or client made with the driver outside src/infra/db (new pg.Pool, mysql2 createPool, new MongoClient): take connections from @dokebi/db (connection(main), derive('main', { purpose, overrides: { max } }) for a pool of one kind of work, or another connection in dokebi.yaml) so dokebi.yaml sets their size and waitTimeoutMs and a caller past the wait gets 503 with Retry-After. overload-off (an error) is a route config overload: false: keep the overload protection on every API route (a metrics scrape or status page takes a marker). readiness-off (an error) is a route config readiness: false: an API route keeps refusing with 503 NOT_READY while a required dependency is down (a metrics scrape or status page that must answer then takes a marker). Warnings: in-process-queue (the resolve of a new Promise stored in a collection to call later: a queue of one worker without bound or deadline), fixed-owner-cap (a per-owner count of requests in progress compared with a fixed limit: use the owner share of @dokebi/db, share(main).enter(owner), which refuses only while the pool is full, @dokebi/db AI.md 6.4), wait-count-refusal (a comparison of the callers waiting for a pool connection, such as pool.waitingCount, with a number: refuse by the wait deadline, never by how many wait), responsibility-size (a model of 400+ code lines over 8+ tables or collections, a controller with 20+ route handlers or 300+ code lines, a service whose methods mostly only pass their arguments to a model) and memory-filtered-page (rows a query returned, skipped in code by an input such as filter.status and then cut to a page). A warning is still a broken rule: fix it, or keep it with // dokebi-check: allow <rule> <reason> and say why in your final message. An allow marker on background-timer is only for per-process upkeep of in-memory state (a cache of this worker); a loop that reads or writes the database (claiming, folding, polling a queue table) is never a valid reason: move it to a job or an outbox handler. check lists every marker with a reason under allowed, and verify ok asks your final message to list them with their reasons. With @dokebi/db installed, verify ok lists finalReport: the section 6 table your final message must contain.",
|
|
44
|
+
'- Input boundaries: validate complete untrusted JSON shapes before services or persistence, including nested editor nodes, marks, attachment identifiers and collection limits. Type assertions and casts do not validate input. Reuse the same identifier schema across URL, body and embedded document fields; Canonicalize accepted identifiers before deduplication and equality checks; equivalent UUID spellings must not become duplicate database bindings. Malformed input must produce a coded client error, not a database cast/constraint error or TypeError. Validate signature/token encodings and byte lengths before cryptographic comparison; character counts do not establish equal byte lengths. Test malformed nested values and non-ASCII token input as well as the valid UI path.',
|
|
45
|
+
'- Storage and concurrency: use public provider APIs, not private driver folders or internal package files. Keep uploads, image conversion and storage deletion outside database transactions and global locks. Bound the entire upload/conversion pipeline, including queued buffers, and account for app worker count. Authorize and revalidate ownership/state in short transactions scoped to the affected draft or resource, including the live containing resource when editing a child. Draft creation is not authorization for a later save: parent deletion or permission changes must invalidate dependent writes consistently with reads. Check that scope in the same transaction as the write, with a lock order shared by cleanup; persist retryable deletion work before external I/O. Treat draft creation, editing, cancellation, soft deletion, physical purge and late upload completion as one state machine: an edit whose target disappears must be rejected, never reinterpreted as a new item. Cleanup must not discard the last tracking record while an upload or metadata commit can still finish; lease expiry needs a bounded lifetime or renewal and a durable late-completion path. Test these interleavings, including database waits, and retain the original failure if compensation also fails. One failed object must not prevent unrelated cleanup from making progress. Persist retry eligibility or a durable progress cursor so permanently failing objects cannot monopolize the first batch on every run. Test more pending objects than the batch size, with a full first batch failing across repeated runs, and prove a later healthy object is still processed. Distinguish payload erasure from physical deletion: when hard cleanup is requested, prune unreferenced deleted leaves, expired drafts and completed file records in bounded batches. Keep structural tombstones only while live descendants need them, and bound retention for abandoned upload tracking without losing late-completion safety. Verify that repeated cleanup converges after descendants are removed. Apply deletion visibility and retention to denormalized content too: notification/activity titles, search projections and previews must not keep exposing erased content or links to purged resources. Test public feeds after both soft deletion and physical purge, not only the original table. Bound every cleanup query as well as file deletion batches. A parent batch does not bound its children: bound affected rows across related tables and foreign-key cascades per transaction, persist progress, and continue later. Test a single deleted parent with more children than the cleanup budget and assert rows affected per pass as well as eventual convergence. Apply YAML limits consistently. If a provider lacks orphan enumeration, document that boundary instead of scanning its private layout or claiming crash reconciliation.',
|
|
46
|
+
'- Database mutation safety: candidate selection is not a claim. A row selected as expired or uploading may change while an UPDATE or DELETE waits for its lock; an id-only outer mutation must not apply a stale eligibility decision. Recheck state, version and expiry on the row being mutated, or claim eligible rows under locks and preserve that claim through the transition. Coordinate completion and cleanup through the same rows and a consistent lock order. Test both lock acquisition orders with two real database connections and explicit barriers; verify the accepted result still has its metadata and bytes after cleanup. Expiry tests may advance fixture timestamps, but must say so instead of claiming a real-duration timeout or load test.',
|
|
47
|
+
'- Query and presentation budgets: select only fields needed by each response; list and navigation summaries should not read or serialize full rich-text bodies. A page that displays full item bodies is a different read model: batch its visible bodies and attachment metadata instead of issuing a detail request per row. Measure the complete rendered journey, including browser fan-out and authorization queries; a bounded SQL list alone can still cause N+1 requests and repeated parent reads. Set explicit per-page request/query, total-node, serialized-byte and rendered-DOM budgets and test them at several page sizes. A root-count limit is not a total-node limit: test one root with thousands of descendants as well as thousands of unrelated roots. Unlimited nesting means reachable descendants through bounded continuation, never loading an entire hierarchy in one response. Keep traversal and ancestor context across continuation boundaries; verify every node is reachable exactly once. A low query count does not establish a bounded response or bounded database work. Paginate hierarchy queries without imposing an artificial nesting limit, and render the actual parent/child relationships across pages. Test branching and page boundaries, not only a short linear chain. Verify representative response sizes, query counts, database execution plans and concurrent operations; limits on returned rows do not bound scanned rows. Inspect plans for the exact production queries, including their computed fields, joins, existence checks and aggregate subplans; a simplified ID-only query does not represent the endpoint. Check actual rows and loops in every data-dependent subplan under representative distributions and growing unrelated data. Include rows removed by filters or index rechecks and buffer work; an index scan can still traverse most of a table. Exercise uniform and skewed key frequencies, common/rare/missing matches and populated as well as empty pages. A plan accepted on one distribution is not a guarantee after data growth or concentration. Do not force planner settings merely to obtain an index-based acceptance result. Check indexed child/root pagination with many unrelated parents and empty pages, and use indexable predicates for nullable parents; a successful startup or a bounded response alone does not establish efficient implementation.',
|
|
48
|
+
'- Database practice (every app that queries a database, whatever the product): read section 6 "Database practice" of `node_modules/@dokebi/db/AI.md` before writing queries, migrations or scheduled jobs, and review each one against it. Before you report done, go through section 6 rule by rule and put a table in your final report: each rule, the queries or jobs it applies to, the measurement (rows read, rate, refusals) and the verdict (pass, or the fix you made); a rule you did not measure is not done. A scan proportional to the whole table measured on a user-facing path or a scheduled job is a defect to fix, not a known boundary to document: change the query or index (equality columns first, then the sort key, sorting in index order) and re-measure on skewed data where one owner holds many rows created after the others. Measure scheduled jobs when nothing is due as well as with a backlog, and with more permanently failing items than one batch at the head of the queue: a failing item records its attempts and next attempt time and leaves the head. Measure frequent values too (a search term in most rows, the busiest owner, the longest report period), compare estimated with actual rows, and keep expensive functions out of per-row predicates. Give DDL on live tables a short lock wait and test it with a transaction held open; keep hot-row writes one short conditional statement taken last in the transaction, shard a count many writers bump at once (N slot rows per key, summed on read) so a burst on one key reaches the rate of the same load spread over many keys and never refuses the main write, and never build in-process queues or per-key mutexes around database writes (they do not serialise app workers and block unrelated keys). Text search follows section 6.10: a term table keyed by (term, display order) so one term reads one page, several terms driven by the rarest with a cap on rows examined per request, never trigram or full-text matches collected and sorted; measure absent, rare, frequent, two-character and common-but-rarely-together terms. Where the language attaches endings to words (Korean particles, Japanese, compound words), index word prefixes by default so a stem finds its inflected forms, and never show a page with no matches alone: the client follows the resume cursor until it has matches or the scan ends. A ranking or any per-key aggregate reads per-bucket ranking rows by an index on (bucket, total) or a stored background report, pages the whole result instead of keeping only its top N, and never aggregates keys x days at request time (section 6.9). A summary over a chosen period (counts per option, averages, a series per day) reads day rows only for short periods and month rows beyond a few months, so a year reads at most 12 month rows plus the partial edge days per question, whatever the number of answers; keep that budget as a test with assertRowBudget on the widest form (most questions) over the longest period. Rows read do not bound the CPU of a report (section 6.9): give report queries their own pool and a deployment-wide concurrency budget (workers x report pool max), keep stored aggregates as small rows per bucket and question (never one large JSON document per year decoded on every read), serve closed months from a cache, fold with one coalesced job per owner and bucket (never one job per answer) on every worker in parallel, on a connection pool apart from requests and claiming the oldest pending owner first, so folding drains faster than the peak arrival rate (record the fold rate and the drain time of the busiest burst), show as the as-of time the oldest answer not yet folded (never the time the fold last ran), and measure the write p50/p95 of other tenants while as many longest-period reports as the budget allows run at once: it stays within 1.5x of the run without reports, and reports past the budget get 429 or an intended 503 (defineErrors intended: true, logged at warn) with Retry-After instead of slowing writes; in that measurement the report clients wait the Retry-After before trying again, as real clients do. Measure every query the app runs, admin, history and export screens included, on page 1, a deep page and with each optional filter (a cursor can switch the plan to another index: when the last page of the busiest owner reads more than one page of rows, make the index and the ORDER BY the same columns in the same order and keep that budget as a test with `measureQuery` and `assertRowBudget` from `@dokebi/db/testing`, measuring the SQL the model sends (`captureQueries`); on MySQL and MariaDB write the keyset cursor expanded (a < ? OR (a = ? AND id < ?)), because they do not use a row comparison as an index range); background jobs read only the range they process through an index and never rescan history; never drop data to bound cost (index every word, keep every row a report counts); and a protection (a concurrency limit, hold cap or queue size) must never refuse work while the resource remains. Overload degrades instead of collapsing: a transaction budget starts once it holds a connection, the connection wait has its own short deadline, and throughput at 2x and 5x the pool stays near the maximum with the excess answered 503. Access links and tokens are stored as hashes. Shed overload per tenant with Retry-After; a filter on nothing set (IS NULL) gets a partial index ending in the sort key; measure backfills on full-size tables; run ANALYZE after bulk changes. Records the product requires (history, audit, counters a report or deadline reads) are written in the same transaction as the change, never best effort with swallowed errors. Load tests use as many independent clients as the concurrency they claim (one browser page opens at most six connections) and push past every pool and wait queue, where overload must answer a coded 503, never a 500. On a seal host send them over real HTTP with `SealedClient` of `@dokebi/seal/testing` (one client per simulated user: sealed forms, JSON and Inertia visits with its own cookies and connections), never only at the service layer. Every job and batch step (cascades, fan-outs, folds, cleanups) moves failing rows past the head, not only the one you tested. Measure the paths of requests that lose (rollback, compensation, retry) under a burst, not only the winners. Background loops claim due work of all tenants through one index in batches with a per-owner cap (never tenant by tenant), wait out an outside outage with per-endpoint backoff for hours before dead-lettering (never delete undelivered work), and use their own connection budget apart from requests; public endpoints are not limited by IP alone (shared NAT). A change updates only the report buckets it touches (never invalidate and refold a whole period); time-critical jobs (reminders, deadlines) run on their own queue apart from heavy folds and exports; every growing table (idempotency keys, notifications, logs) has a retention and a bounded cleanup; DDL never scans a full table under an exclusive lock (PostgreSQL: NOT VALID then VALIDATE, indexes CONCURRENTLY; MySQL: ALGORITHM=INSTANT or INPLACE with LOCK=NONE); list positions change under a lock on the list or a unique (list, position) key with sparse keys; and each optional filter is measured at its rarest on the busiest owner. Every list is paged, including the history and comments of a record, and a picker over people or items is a server-side search, never the first N options. Cursors and resume points keep the full timestamp precision (a JavaScript Date drops microseconds) or page by the unique id, and a job handler checks its abort signal between bounded batches so it ends inside its time limit. A queue that owns freed capacity takes it in the transaction that frees it, so outsiders cannot jump it and capacity never sits idle. A scheduled job keeps taking bounded batches while due work remains so it drains faster than work arrives (measure a backlog the size of the busiest hour, and put the burst arrival rate and the measured drain rate of every job in the section 6 table), and state kept in a session, cache entry or JSON column and rewritten whole is a race: use rows with conditional statements or a version check. Validate cursors (a bad cursor is a coded 400), refuse an idempotency key reused with different content, and treat caps (lines per cart) as races decided under a lock on the owning row or by a conditional counter. Paginate on the server: every list endpoint, page or Inertia prop sends one page from a LIMITed query with a cursor for the next one; never load hundreds or thousands of rows to page, filter, sort or search them in the browser or in application memory. Page by keyset with a unique tiebreaker, avoid exact counts and N+1 on every page, keep external I/O out of transactions, let unique constraints and conditional updates decide races, stock and quotas, and pass every value as a parameter.',
|
|
49
|
+
"- Required languages (consumer convention; the core plugin remains optional): install @dokebi/i18n for every browser-facing web app and implement both Korean (ko) and English (en), including navigation, forms, validation messages and language switching. Keep catalogs in src/shared/locales/<locale>/<namespace>.json, split by feature (ko/auth.json, en/auth.json, ko/<feature>.json, en/<feature>.json). Use stable nested keys such as auth.password.minimum and the documented key/default pair; never use translated sentences as keys or translation arrays. Enable documented request/HTTP integration and preserve locale across Inertia navigation and reloads. When the locale is in the URL (/<locale>/...), set dokebi-i18n http.pathLocale: true (for a route parameter call useUrlLocale(request, reply, locale) before rendering) and switch languages by navigating to the other URL, not with switchLocale (node_modules/@dokebi/i18n/AI.md, Locales in the URL). For Vue views, use the framework runtime @dokebi/template-inertia/i18n on the same catalogs: the generated views/app.js and views/ssr.js install pageI18n, the shared i18n prop delivers the page's namespaces (http.pageMessages, pageMessages(request, ...) for more), and pages call useI18n() for t(), n(), d() and switchLocale() (or $t() in templates); do not add vue-i18n, write another browser runtime or invent another server translation bridge. Test both languages, matching keys and placeholders, fallback, selected-language persistence and validation responses. A headless API the user asked for (its clients are other programs), core-only and worker-only projects do not need a browser transport or language selector. Localize their messages only where the product requires it.",
|
|
50
|
+
'- @dokebi/rate-limit: define named policies in YAML and bind limiters once during setup. Local counters belong to one process; use Redis for a shared multi-worker limit. The HTTP helpers are opt-in. Keys must come from authenticated identity or a trusted request IP, never an unchecked forwarded header.',
|
|
51
|
+
'- @dokebi/storage: choose local or S3-compatible storage in YAML. The provider does not create public upload routes or authorization. When the product permits uploads before saving, stage them under an authorized owner or an explicitly designed guest capability, then attach IDs in the resource transaction. Guest upload permission is a product decision, not implied by storage installation. Cancelling edits must preserve existing attachments. Apply the chosen retention policy to temporary files and committed deletion; do not invent a purge interval or erase retained/audited content.',
|
|
52
|
+
'- @dokebi/image: thumbnails of stored images come from image.thumbnail({ key, maxSide }) on the storage key of your file record; do not decode uploads yourself with sharp, canvas or a browser-side resize. Serve them with sendThumbnail of @dokebi/image/http after your own access check of the record (it sets ETag, Cache-Control and answers 304). On UNSUPPORTED_IMAGE or IMAGE_TOO_LARGE show the original small or an icon; BUSY and IMAGE_TIMEOUT are retryable 503s. Delete the file through storage and its thumbnails go with it. Every limit is a dokebi.yaml option of dokebi-image; read `node_modules/@dokebi/image/AI.md`.',
|
|
53
|
+
'- @dokebi/mcp pictures (D-0143): a tool that lets the model see a picture returns it in envelope({ images: [{ data, mimeType, alt, source }] }) with the bytes (a Buffer) and image/png, image/jpeg, image/webp or image/gif; the route sends MCP image blocks after the text block and the envelope keeps only placeholders. Never put base64 into content or fields: the model reads it as text and it counts against resultMaxChars. Send a reduced copy, not the original: an image.thumbnail({ key, maxSide }) of @dokebi/image (image/webp) read with open(thumb).body, checked against context.images.imageMaxBytes. The bytes decide the type (IMAGE_TYPE_MISMATCH, IMAGE_TYPE_REFUSED); maxImages, imageMaxBytes and imageTotalMaxBytes are dokebi.yaml options of dokebi-mcp (TOO_MANY_IMAGES, IMAGE_TOO_LARGE, IMAGE_TOTAL_TOO_LARGE); read the Pictures part of `node_modules/@dokebi/mcp/AI.md`.',
|
|
54
|
+
'- @dokebi/pdf (D-0144): the text of a stored PDF comes from pdf.text({ key, from, to, maxChars }) as page parts { page, text, chars }, and a first-page WebP thumbnail from pdf.thumbnail({ key, maxSide }); do not parse PDF files yourself with pdfjs-dist, pdf-parse or a shell tool, and never run that work on the request thread. Check your own record of the file first. A scan answers hasText false (there is no OCR); say so instead of inventing text. Page and character limits truncate (truncated, pagesRead, nextPage); bytes, memory and time refuse with PDF_TOO_LARGE or PDF_TIMEOUT; NOT_PDF, PDF_ENCRYPTED and PDF_DAMAGED are refusals to show, not errors to retry; BUSY and PDF_TIMEOUT are retryable 503s. An MCP tool that reads a PDF returns one envelope content item per page part with outside: true and passes maxChars from context.resultMaxChars. Every limit is a dokebi.yaml option of dokebi-pdf; read `node_modules/@dokebi/pdf/AI.md`.',
|
|
55
|
+
'- @dokebi/pdf-writer (D-0157): write PDF files (exports, invoices, reports) with pdfWriter.write({ markdown | blocks, filename, title?, images? }) in a background handler (@dokebi/outbox or @dokebi/job), never on the request thread; it stores the PDF in @dokebi/storage and returns { key, pages, size, skippedImages, missingGlyphs }. Do not write PDF bytes yourself or install another PDF library: its bundled Noto Sans KR covers Korean, Hanja, kana and Latin, and fallbackFonts adds more. Markdown HTML is printed as text and only http(s) and mailto links become links. Images are drawn only through the images map { reference: storage key } you pass after your own access check (JPEG and PNG; WebP and others come back in skippedImages). PDF_TOO_LARGE names the limit; BUSY and PDF_TIMEOUT are retryable 503s. Read `node_modules/@dokebi/pdf-writer/AI.md`.',
|
|
56
|
+
'- @dokebi/geoip (D-0158): the location of an address comes from geoip.locate(request.ip, { precision }) as { country, region, city? } or null, and every field from geoip.lookup(request.ip) (unknown with reason invalid, private, no_database or not_found); never call an IP geolocation web API, parse .mmdb files yourself or read X-Forwarded-For (request.ip follows the trustProxy rules of dokebi-web). The location is personal data: keep the location at the precision the product needs, never the address. The operator provides the database (MaxMind GeoLite2 by default, or DB-IP Lite) with npx dokebi-geoip download from URLs in DOKEBI_GEOIP_CITY_URL and DOKEBI_GEOIP_ASN_URL, never committed; show the attribution its license requires. Without a database the app starts and locate answers null. Read `node_modules/@dokebi/geoip/AI.md`.',
|
|
57
|
+
'- Browser files of an app (R1): style sheets, images, fonts and browser scripts of an EJS app go in src/apps/<app>/public/ and are linked as /assets/<path>; npx dokebi add @dokebi/static, and @dokebi/app serves that folder on the app hosts (ETag, 304, Range). Never keep them in views/ and never serve them with a route that reads the file yourself (dokebi check warns asset-in-views and asset-route); a font or image a package ships (@dokebi/font-noto-kr) is copied into public/ with its licence file. An Inertia app imports them through Vite.',
|
|
58
|
+
'- Files from a folder (D-0147): an operator folder served as it is (app update feeds such as appcast.xml, latest.json or latest-mac.yml and the installers they name) is a mount of @dokebi/static (npx dokebi add @dokebi/static; dokebi-static mounts.<name>: { prefix, root, cache: [{ match, cacheControl }] }, or static.mount(name, mount) in a setup), not a route you write with fs and createReadStream. It answers GET and HEAD through @fastify/static with ETag, Last-Modified, 304, 412, one Range (206, 416, If-Range), Cache-Control by pattern (immutable for versioned files, no-cache for feeds), never a listing and never a path outside the root, and never the in-progress names of an upload (hide: **.part, **.tmp, **.uploading): upload under such a name, then rename into place. Its route id is static.<name>. Private or per-user files stay in @dokebi/storage behind your own access check (sendStored).',
|
|
59
|
+
'- Security headers and overload protection are built into @dokebi/web and on by default (D-0149, D-0150): every answer gets X-Content-Type-Options: nosniff, HTTPS answers Strict-Transport-Security, HTML pages a Content-Security-Policy (default-src self, frame-ancestors self, inline scripts and styles allowed), X-Frame-Options and Referrer-Policy; while the event loop stalls (dokebi-web overload.maxEventLoopDelayMs, 200 ms) requests get 503 OVERLOADED with Retry-After and health routes are served. Do not add @fastify/helmet, @fastify/under-pressure or headers of your own for this. To load from a CDN or embed a page, change dokebi-web securityHeaders.contentSecurityPolicy.directives in dokebi.yaml (npx dokebi config set) or set config: { securityHeaders: { … } } on that route; give a metrics scrape route config: { overload: false } with // dokebi-check: allow overload-off <reason> on that line (npx dokebi check reports overload-off as an error, and verify fails, without it), and never an API route. The same routes also answer while a required dependency (a database, Redis) is down or the app drains with config: { readiness: false } and // dokebi-check: allow readiness-off <reason> on the line before it; without it they are refused with 503 NOT_READY like every other route. Never turn securityHeaders off to make a page work: read which directive the browser console names and change that one.',
|
|
60
|
+
'- CORS (D-0148): only when the user asks that pages on other origins call this server, install @dokebi/cors (npx dokebi add @dokebi/cors) and list the origins in dokebi-cors origins (or apps.<app>.origins per app); never * with credentials, and never register @fastify/cors yourself. Same-origin pages, server to server calls and mobile apps need no CORS. A cross-origin POST with cookies still passes dokebi-csrf: add the origin to dokebi-csrf trustedOrigins and send the token, or use Bearer tokens; SSE streams list their own origins.',
|
|
61
|
+
'- Behind a tunnel or reverse proxy (Cloudflare Tunnel, ngrok, nginx): set dokebi-web.trustProxy to the proxy address as the app sees it, [127.0.0.1, ::1] for a tunnel on this machine (the tunnel may keep its HTTP Host Header localhost), and list the public host in dokebi-app.domains (in development also dokebi-template-inertia.devHosts). Without trustProxy requestHost() is localhost: the public host is not matched, sealed Inertia pages do not decrypt in the browser, and the log warns WEB_FORWARDED_HOST_IGNORED. Check that web.requestHost(request) equals the address bar host. Development HMR works through the tunnel with nothing to set. To keep a route plain on a seal host put seal: false on the route itself (route({ method, url, seal: false })), never in its config (that fails the start with INVALID_ROUTE_CONFIG).',
|
|
62
|
+
'- App logging (D-0151): log through the logger of the plugin context (context.logger in a setup or hook) or of the app (app.logger in src/main.ts), never console.log or process.stdout.write in server code (npx dokebi check warns console-output). A new project has @dokebi/logger: src/main.ts passes createLogger(document.options) from src/dokebi.generated.ts to the Application, and options.logging in dokebi.yaml sets the level, the level per plugin (plugins: { dokebi-web: warn }), the targets (stdout JSON lines, pretty lines for dokebi dev, a rotated file) and redact (keys and dot paths besides the built-in secret names such as password, token, authorization and cookie). Write a fixed English message and a code in each record and pass the error itself (error: err keeps the name, the whole message, the stack and the causes); never log request bodies, tokens or passwords on purpose. DOKEBI_LOG_LEVEL still overrides the app level. Turn the file target on in production on a server that keeps its own log files; containers keep stdout, and under dokebi start the log writer already rotates .dokebi/run/app.log, so the file target is skipped there. To ship records to a log receiver add a network target (D-0152): type http (NDJSON batches, for Dokebi Studio url https://log.dokebi.app/v1/logs with the token of the app server as a secret token in dokebi.yaml written by npx dokebi config secret logging.targets.<name>.token --stdin, never on a command line) or type otlp (an OpenTelemetry collector); it sends from its own worker thread, adds hostname and pid, honours Retry-After and never blocks the app. Without the plugin (npx dokebi remove @dokebi/logger) the app writes with the console logger of core. Read `node_modules/@dokebi/logger/AI.md`.',
|
|
63
|
+
'- @dokebi/metrics: optional HTTP metrics; read its AI.md for collector options and endpoint exposure. Do not replace application logs or expose operational data publicly by default.',
|
|
64
|
+
'- @dokebi/mail and @dokebi/auth: use queued mail and the documented reset/signup tokens, keep private links in sealed payloads, and retain mail templates even in an Inertia app.',
|
|
65
|
+
'- @dokebi/cluster, @dokebi/job and @dokebi/worker solve different tasks: app processes, durable jobs and CPU threads. For multiple app workers, read `node_modules/@dokebi/cluster/AI.md`, keep the runner in src/main.ts and worker factory in src/infra/runtime/application.ts, and resolve one absolute DOKEBI_CONFIG before forking. Share session/auth/rate-limit state through configured providers. A one-use value in the session (a pending consent, a form nonce, a confirmation ticket) goes through await request.session.setOnce(key, value, { ttlMs }) and await request.session.takeOnce(key), never get then delete: of two workers or a form posted twice exactly one takes it. With @dokebi/seal, share the replay nonces of sealed requests too: set dokebi-seal nonces: { driver: redis, connection: <a dokebi-redis connection> } (no WebSocket hub needed; a hub in mode: cluster also works); per-process nonces in a cluster worker refuse to start (SEAL_NONCES_NOT_SHARED). Several workers and production need Redis for the state the processes share (D-0161): dokebi-session store, dokebi-auth tokens and throttle.store, dokebi-oauth store, dokebi-rate-limit driver, dokebi-seal nonces and dokebi-websocket-hub mode: cluster, each as { driver: redis, connection: <a dokebi-redis connection> } (auth tokens and oauth may use sql). A memory store refuses to start in a cluster worker and in a deployment with NODE_ENV=production (an app the CLI runs here, dokebi start, verify or dev, sets DOKEBI_LOCAL_RUN=1 and keeps memory); npx dokebi check reports it as shared-state-memory when application-runtime.workers is above 1. One process in development keeps memory. Development uses one worker unless DOKEBI_DEV_WORKERS is set.',
|
|
66
|
+
'- Configuration scope: YAML owns application/runtime settings and secrets. Keep standard tool files such as tsconfig.json, package.json and Vite configuration in their supported formats; do not delete the TypeScript project file or generate it only during checks. Editors and the CLI must see the same maintained project. If Vue tooling needs a compatible compiler version, declare and lock that toolchain explicitly without patching node_modules.',
|
|
67
|
+
"- Route locking with @dokebi/auth: to lock every route by default set dokebi-auth.default: required (top level or apps.<app>.default) and open only the app's own public routes: the login page config: { auth: 'guest' }, sign-up, password reset, public pages and static files the app serves itself config: { auth: false }. Routes that framework plugins serve before a sign-in (health, template-inertia built assets and Vite dev modules, seal browser modules, OAuth and MCP endpoints, the mail development inbox) already declare auth: false, and SSE/WebSocket routes stay guarded by the default. Do not fall back to default: optional with a hand-written route-policy test to keep login and assets reachable; check the effective rule per route with npx dokebi describe (the auth section's routes).",
|
|
68
|
+
'- Reconfirmation and devices: before a sensitive action on an account without a password, start the provider again with social.begin(provider, { reconfirm: await request.auth.reconfirmBinding() }) and, when the callback identity has reconfirm, call request.auth.confirmSocial(identity, user); never count an ordinary social sign-in callback as a confirmation, and ask confirmedRecently(). List and end devices with request.auth.devices() and request.auth.endDevice(id); never show session ids or cookie values and never scan the session store yourself. User agents are kept only with dokebi-session.devices.userAgent: true, and device locations (location: { country, region, city? }, never the address) only with dokebi-session.devices.location.enabled: true and @dokebi/geoip installed (both personal data). To ask for a fresh authentication without signing out, set dokebi-auth.reauthenticatePath to a page (auth: required) that calls confirmPassword() or starts that social reconfirmation and then redirects to takeReturnPath(), and send users there with await request.auth.reauthenticate(); await request.auth.authenticatedAt() is the last authentication (a password login, login(user), the sign-in behind a remember cookie, or a confirmation) and is what @dokebi/oauth uses for prompt=login, max_age and auth_time. With @dokebi/oauth every authorization is its own grant and token family, so bind a device to request.oauth.grant.id; at most grant.maxPerClient live grants per user and client, the least recently used is signed out beyond it.',
|
|
69
|
+
'- Authentication lifetime: auth.load() and auth.id only identify stored session data; they do not prove that the account or stamp is still valid. Use auth.user() or a protected route before returning private data, including optional/public endpoints with a personal-data branch. For SSE/WebSocket authenticated by a session or by a Bearer API token, use auth.live() (auth >=0.4.1, session >=0.4.4) and return its lease as identity.authorization (sse/websocket >=0.4.2); for an @dokebi/oauth access token use await oauth.live(request) after oauth.authenticate(); reject missing leases. The default revocation bound is live.maxAgeMs in dokebi.yaml (5000 ms; a logout, logoutOtherDevices, password reset or token revocation in the same process re-checks at once, and a shorter live(ms) costs proportionally more reads); this checks session removal/rotation/expiry or the token row and its expiry, and current account existence, active state and stamp, independently of the HTTP user cache, so a password change or a disabled account also ends Bearer streams. Do not replace it with handshake-only checks or a browser logout event. Verify already-open inbound and outbound traffic after logout, credential reset and account disablement, including another worker and provider failure. Resource/role permissions remain application responsibilities; in-flight operations need their own transaction rules.',
|
|
70
|
+
'- Live collection efficiency: avoid periodic full-list polling per connected client. Use change notifications (WebsocketApi.onChange includes membership, rooms and metadata), a shared per-worker snapshot/read model and bounded deltas; obtain a fresh snapshot on connection/reconnection. Coalesce simultaneous reads and changes, order snapshots/deltas with revisions, and handle deltas arriving before the initial snapshot. Remove subscriptions and timers on disconnect/shutdown. Measure idle traffic, data-source reads and change fanout separately as clients and tabs increase; do not describe calculated byte counts as a load benchmark.',
|
|
71
|
+
'- Public identity and diagnostics: use an explicit public display name in public DTOs; do not expose login email addresses as author labels by default. Keep private authentication fields out of public lists, detail views and search. Test anonymous responses. Error logs must retain the actual exception and cause chain with a stable code while excluding secrets; inject provider failures and assert the captured error field, not only a retry counter. Keep validation messages in the selected-language catalogs. Shared page layout owns the outer width and gutters for pages designed to align; intentional alternate layouts belong to the product design.',
|
|
72
|
+
'- Product acceptance: preserve each requested attachment placement independently. Inline images, inline downloadable files and a separate attachment list are different capabilities; do not infer placement solely from MIME type. Keep creation, editing, duplication and child-resource creation as distinct initialization modes where supported. Editing starts from the target item attachments; duplication and related-resource creation inherit content only under explicit product rules and renewed ownership checks. Verify these modes with existing attachments and route changes, not just empty fixtures. Test consecutive mixed insertions and edits in the real editor without manually repairing its selection between actions. Adding a resource must preserve earlier content unless the user explicitly chose replacement; verify both the editor and the persisted result, including cancellation. Map each user requirement to an observable UI/API check before declaring completion.',
|
|
73
|
+
'- CLI 0.4.9: `npx dokebi dev-infra` prepares Compose and YAML without generating .env. Start required services with `npx dokebi dev-infra up` (repeat `--file <path>` for additional Compose files). The password comes from YAML only in the Docker child environment. Persist fixed loopback host ports in Compose when URLs are saved in YAML; Docker-assigned ephemeral ports can change even after stop/start. Preserve existing passwords and data volumes. App dev/start/verify do not start stopped infrastructure.',
|
|
74
|
+
'- CLI 0.4.9: `config set` refreshes conditional dependencies in the same transaction. After manual YAML edits, setting the same option reconciles state; never edit generated metadata to clear STALE_REQUIREMENTS.',
|
|
75
|
+
'- Settings of the application and reloads (D-0155, D-0154): declare the settings your own code needs (limits, sizes and times of a feature) with npx dokebi settings add <section>: it writes src/shared/settings/<section>.ts (defineSettings with a defineOptions declaration: every number with minimum, maximum and default), its schema file and the defaults in dokebi.yaml, and the app checks options.<section> at start. Read them where they are used with <name>Settings.read(context.app.config), never with a parser of your own over app.config; after changing the declaration run npx dokebi settings sync. Mark reloadable: true the options a running app may take without a restart; the CLI writes # reloadable after them in dokebi.yaml. A running app reads dokebi.yaml again when it is saved, on npx dokebi reload after dokebi start, and dokebi dev applies such a change without a restart; a change of an unmarked option is refused as a whole (RELOAD_RESTART_REQUIRED) and needs a restart.',
|
|
76
|
+
'- An option a plugin does not know (INVALID_PLUGIN_OPTIONS, often after an upgrade moved or dropped a key) is removed with npx dokebi config unset <id>.<path> (D-0153): the error fix names the exact command, and for a key that moved to another plugin it says where the key went and gives the config show, unset, add and set steps. It edits dokebi.yaml and dokebi.example.yaml in one transaction and prints no values; never drop keys with sed or by hand.',
|
|
77
|
+
'- EJS 0.4.3 and CLI 0.4.9 separate engine installation from web page scaffolding. Inertia-only projects need no shared EJS web layout. Preserve engine entries, mail templates and user-modified resources; remove obsolete layout settings together with unused layouts after checking references.',
|
|
78
|
+
];
|
|
79
|
+
/** Catalog metadata is the single source of the available package descriptions. */
|
|
80
|
+
export function capabilityBlock(catalog) {
|
|
81
|
+
const cell = (value) => value.replaceAll('|', '\\|').replaceAll('\n', ' ');
|
|
82
|
+
return [
|
|
83
|
+
CAPABILITIES_START,
|
|
84
|
+
'## Framework capabilities and current guidance',
|
|
85
|
+
'',
|
|
86
|
+
'The CLI refreshes this block on add/upgrade/remove and `npx dokebi guide --sync`. Keep project-specific rules outside it. Available does not mean installed: use the installed table and `npx dokebi inspect --json` before importing a package. Read the installed package AI.md before implementation; use `npx dokebi plugins info <id> --json` before installation.',
|
|
87
|
+
'',
|
|
88
|
+
'| Package | Purpose | Contract after installation |',
|
|
89
|
+
'|---|---|---|',
|
|
90
|
+
...catalog.packages.map((item) => `| ${cell(item.package)} | ${cell(item.summary)} | ${item.docs.ai ? `node_modules/${item.package}/${item.docs.ai}` : 'Use plugins info and the package README'} |`),
|
|
91
|
+
'',
|
|
92
|
+
'### Integration rules',
|
|
93
|
+
'',
|
|
94
|
+
...INTEGRATION_RULES,
|
|
95
|
+
CAPABILITIES_END,
|
|
96
|
+
].join('\n');
|
|
97
|
+
}
|
|
98
|
+
/** Never overwrite custom prose or a project/installed block with malformed boundaries. */
|
|
99
|
+
export function syncCapabilityBlock(text, block) {
|
|
100
|
+
const starts = text.split(CAPABILITIES_START).length - 1;
|
|
101
|
+
const ends = text.split(CAPABILITIES_END).length - 1;
|
|
102
|
+
if (!starts && !ends) {
|
|
103
|
+
const anchor = text.indexOf('## Installed packages');
|
|
104
|
+
return anchor < 0
|
|
105
|
+
? `${text.trimEnd()}\n\n${block}\n`
|
|
106
|
+
: `${text.slice(0, anchor)}${block}\n\n${text.slice(anchor)}`;
|
|
107
|
+
}
|
|
108
|
+
const start = text.indexOf(CAPABILITIES_START);
|
|
109
|
+
const end = text.indexOf(CAPABILITIES_END) + CAPABILITIES_END.length;
|
|
110
|
+
if (starts !== 1 || ends !== 1 || end <= start)
|
|
111
|
+
return undefined;
|
|
112
|
+
const inside = text.slice(start + CAPABILITIES_START.length, end - CAPABILITIES_END.length);
|
|
113
|
+
if (/<!-- dokebi:(?:project|installed):/.test(inside))
|
|
114
|
+
return undefined;
|
|
115
|
+
for (const name of ['project', 'installed']) {
|
|
116
|
+
const a = text.indexOf(`<!-- dokebi:${name}:start -->`);
|
|
117
|
+
const b = text.indexOf(`<!-- dokebi:${name}:end -->`);
|
|
118
|
+
if (a >= 0 && b >= 0 && start < b && end > a)
|
|
119
|
+
return undefined;
|
|
120
|
+
}
|
|
121
|
+
return text.slice(0, start) + block + text.slice(end);
|
|
122
|
+
}
|
package/dist/catalog.d.ts
CHANGED
|
@@ -12,6 +12,11 @@ export interface CatalogVariant {
|
|
|
12
12
|
readonly version: string;
|
|
13
13
|
};
|
|
14
14
|
readonly migrate?: boolean;
|
|
15
|
+
/**
|
|
16
|
+
* D-0112: the adapter template's pool limits with their defaults (`max`, `waitTimeoutMs`, …),
|
|
17
|
+
* written beside `url` into options.dokebi-db.connections.main.options by `add --adapter`.
|
|
18
|
+
*/
|
|
19
|
+
readonly connectionOptions?: Readonly<Record<string, number>>;
|
|
15
20
|
readonly alsoInstalls?: readonly string[];
|
|
16
21
|
readonly needs?: readonly string[];
|
|
17
22
|
readonly devInfra?: {
|
|
@@ -66,6 +71,8 @@ export interface CatalogOption {
|
|
|
66
71
|
readonly plugins: readonly string[];
|
|
67
72
|
readonly alsoInstalls: readonly string[];
|
|
68
73
|
readonly needs: readonly string[];
|
|
74
|
+
/** Offered only when other answers match (`{ db: '!none' }`: admin login needs a database). */
|
|
75
|
+
readonly when?: Readonly<Record<string, string>>;
|
|
69
76
|
readonly absentFeatures: readonly string[];
|
|
70
77
|
readonly pros: readonly string[];
|
|
71
78
|
readonly cons: readonly string[];
|
|
@@ -74,6 +81,7 @@ export interface CatalogChoice {
|
|
|
74
81
|
readonly id: string;
|
|
75
82
|
readonly flag: string;
|
|
76
83
|
readonly from: 'variants' | 'fixed';
|
|
84
|
+
readonly input?: 'text';
|
|
77
85
|
readonly when?: Readonly<Record<string, string>>;
|
|
78
86
|
readonly question: string;
|
|
79
87
|
readonly default: string;
|
|
@@ -93,14 +101,19 @@ export interface CatalogRecipe {
|
|
|
93
101
|
readonly why: string;
|
|
94
102
|
};
|
|
95
103
|
/**
|
|
96
|
-
* `forms`: the app takes
|
|
97
|
-
*
|
|
104
|
+
* `forms`: the app takes form posts (a write form); with a server-rendered view the recipe turns
|
|
105
|
+
* dokebi-web `formBody` on, and every plan installs the `csrf` provider (D-0076 §6).
|
|
98
106
|
*/
|
|
99
107
|
readonly web: {
|
|
100
108
|
readonly port: number;
|
|
101
109
|
readonly domain: string;
|
|
102
110
|
readonly forms?: boolean;
|
|
103
111
|
};
|
|
112
|
+
/**
|
|
113
|
+
* D-0098: `false` when the recipe builds no browser screens (the api recipe: its clients are
|
|
114
|
+
* other programs). The plan says so, and the record keeps `view = none` with who chose it.
|
|
115
|
+
*/
|
|
116
|
+
readonly screens?: boolean;
|
|
104
117
|
readonly then?: readonly string[];
|
|
105
118
|
}
|
|
106
119
|
export interface CatalogAbsent {
|
|
@@ -136,6 +149,7 @@ export declare function findPackage(catalog: Catalog, key: string): CatalogPacka
|
|
|
136
149
|
export declare const HOST_CAPABILITY: {
|
|
137
150
|
readonly apps: 'web.apps';
|
|
138
151
|
readonly modules: 'web.modules';
|
|
152
|
+
readonly logger: 'logging.targets';
|
|
139
153
|
};
|
|
140
154
|
/** Packages that provide a capability (in `provides` or one of their variants). */
|
|
141
155
|
export declare function providersOf(catalog: Catalog, capability: string): CatalogPackage[];
|
package/dist/catalog.js
CHANGED
|
@@ -45,7 +45,11 @@ export function findPackage(catalog, key) {
|
|
|
45
45
|
* The catalog capability of each install-time host role (D-0070 `dokebi.hosts`): which plugin to
|
|
46
46
|
* suggest when a project has no host for `dokebi app …` or `dokebi module …` yet.
|
|
47
47
|
*/
|
|
48
|
-
export const HOST_CAPABILITY = {
|
|
48
|
+
export const HOST_CAPABILITY = {
|
|
49
|
+
apps: 'web.apps',
|
|
50
|
+
modules: 'web.modules',
|
|
51
|
+
logger: 'logging.targets',
|
|
52
|
+
};
|
|
49
53
|
/** Packages that provide a capability (in `provides` or one of their variants). */
|
|
50
54
|
export function providersOf(catalog, capability) {
|
|
51
55
|
return catalog.packages.filter((item) => item.provides.includes(capability));
|
|
@@ -91,20 +95,28 @@ export function searchCatalog(catalog, query) {
|
|
|
91
95
|
return terms.some((term) => text.includes(term));
|
|
92
96
|
})
|
|
93
97
|
.map((recipe) => ({ name: recipe.name, summary: recipe.summary }));
|
|
94
|
-
|
|
95
|
-
|
|
98
|
+
// The entries that match more of the query come first (AI-M45: "generate pdf" listed the
|
|
99
|
+
// admin screens' "generated" before the missing PDF generator).
|
|
100
|
+
const hits = (entry) => {
|
|
96
101
|
const text = `${capabilityText(entry.capability)} ${entry.summary}`.toLowerCase();
|
|
97
|
-
return terms.
|
|
98
|
-
}
|
|
99
|
-
|
|
102
|
+
return terms.filter((term) => text.includes(term)).length;
|
|
103
|
+
};
|
|
104
|
+
const absent = catalog.absent
|
|
105
|
+
.map((entry) => ({ entry, hits: hits(entry) }))
|
|
106
|
+
.filter((item) => item.hits > 0)
|
|
107
|
+
.sort((a, b) => b.hits - a.hits)
|
|
108
|
+
.map(({ entry }) => ({
|
|
100
109
|
...entry,
|
|
101
110
|
message: `Dokebi has no ${entry.capability}: do not invent a plugin, flag or API for it. ${entry.instead}`,
|
|
102
111
|
}));
|
|
112
|
+
// Beside found packages, the missing capabilities a term names (`pdf` → pdf.generate): a
|
|
113
|
+
// package that reads PDFs must not pass for one that writes them (AI-M45).
|
|
114
|
+
const named = (capability) => terms.includes(capability) || capability.split('.').some((part) => terms.includes(part));
|
|
103
115
|
return {
|
|
104
116
|
query,
|
|
105
117
|
status: matches.length ? 'found' : absent.length ? 'absent' : 'unknown',
|
|
106
118
|
matches,
|
|
107
119
|
recipes,
|
|
108
|
-
absent: matches.length ? absent.filter((entry) =>
|
|
120
|
+
absent: matches.length ? absent.filter((entry) => named(entry.capability)) : absent,
|
|
109
121
|
};
|
|
110
122
|
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { Problem } from './problems.js';
|
|
2
|
+
import { type Kinds, type TsNode, type TsSourceFile } from './typescript.js';
|
|
3
|
+
export declare function assetProblems(root: string): Promise<Problem[]>;
|
|
4
|
+
export declare const ASSET_ROUTE_FIX = "put the file in src/apps/<app>/public/ and link /assets/<path> (a file a package ships, such as a font of @dokebi/font-noto-kr, is copied there with its licence file): @dokebi/app serves that folder on the app hosts through @dokebi/static (npx dokebi add @dokebi/static) with a validator, 304 and Range, where this route sends the whole file on every request. A file the route makes per request (a generated image) takes // dokebi-check: allow asset-route <reason>";
|
|
5
|
+
/** asset-route: a GET (or HEAD) route for a browser file whose handler reads the file itself. */
|
|
6
|
+
export declare function assetRouteRules(file: TsSourceFile, kinds: Kinds, report: (node: TsNode, message: string, fix: string) => void): void;
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
// The asset rule of `dokebi check` (a warning, R1 measurement, user instruction 2026-10-28):
|
|
2
|
+
// asset-in-views a browser file (a style sheet, an image, a font) in the views folder of an
|
|
3
|
+
// EJS app (src/apps/<app>/views/): templates live there; the app's browser
|
|
4
|
+
// files belong in src/apps/<app>/public/, which @dokebi/app serves at /assets/
|
|
5
|
+
// through @dokebi/static. R1 builders kept CSS in views/ and served it with a
|
|
6
|
+
// readFile route of their own. An Inertia app (views/app.js, its Vite entry)
|
|
7
|
+
// imports its styles through Vite and is not read.
|
|
8
|
+
// asset-route (a warning, R6 Codex T3 review) a GET or HEAD route for a browser file: its
|
|
9
|
+
// path is a fixed file name with a browser file's extension (`/site.css`), or
|
|
10
|
+
// ends in one and the handler reads a file itself (`createReadStream`,
|
|
11
|
+
// `readFile`, `readFileSync`). The guidance said check warns about such a route;
|
|
12
|
+
// the R6 builder streamed a 4.6 MB font of a package with no validator and no
|
|
13
|
+
// Range, R1 builders served CSS read once at start (`/site.css`) or by a
|
|
14
|
+
// controller (`/assets/app.css`), and check was silent. Server code only.
|
|
15
|
+
import { readdir, stat } from 'node:fs/promises';
|
|
16
|
+
import { join } from 'node:path';
|
|
17
|
+
import { debug, isMissing } from '../diagnostics.js';
|
|
18
|
+
import { CliError } from '../errors.js';
|
|
19
|
+
import { field, nodes } from './typescript.js';
|
|
20
|
+
const RULE = 'asset-in-views';
|
|
21
|
+
/** Browser files by extension (lower case): never a template. */
|
|
22
|
+
const ASSET = /\.(css|png|jpe?g|gif|webp|avif|svg|ico|woff2?|ttf|otf)$/i;
|
|
23
|
+
/** Files read per app at most: a views folder is small. */
|
|
24
|
+
const MAX_FILES = 2000;
|
|
25
|
+
async function exists(path) {
|
|
26
|
+
try {
|
|
27
|
+
await stat(path);
|
|
28
|
+
return true;
|
|
29
|
+
}
|
|
30
|
+
catch (error) {
|
|
31
|
+
if (isMissing(error))
|
|
32
|
+
return false;
|
|
33
|
+
throw new CliError('READ_FAILED', `Cannot read ${path}`, { cause: error });
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
/** Every file under `folder`, relative to `root`, up to MAX_FILES. */
|
|
37
|
+
async function files(root, folder) {
|
|
38
|
+
const found = [];
|
|
39
|
+
const walk = async (relative) => {
|
|
40
|
+
let entries;
|
|
41
|
+
try {
|
|
42
|
+
entries = await readdir(join(root, relative), { withFileTypes: true });
|
|
43
|
+
}
|
|
44
|
+
catch (error) {
|
|
45
|
+
if (isMissing(error))
|
|
46
|
+
return;
|
|
47
|
+
throw new CliError('READ_FAILED', `Cannot read ${relative}`, { cause: error });
|
|
48
|
+
}
|
|
49
|
+
for (const entry of entries) {
|
|
50
|
+
if (found.length >= MAX_FILES)
|
|
51
|
+
return;
|
|
52
|
+
const path = `${relative}/${entry.name}`;
|
|
53
|
+
if (entry.isDirectory())
|
|
54
|
+
await walk(path);
|
|
55
|
+
else if (entry.isFile())
|
|
56
|
+
found.push(path);
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
await walk(folder);
|
|
60
|
+
return found;
|
|
61
|
+
}
|
|
62
|
+
export async function assetProblems(root) {
|
|
63
|
+
let apps;
|
|
64
|
+
try {
|
|
65
|
+
apps = (await readdir(join(root, 'src/apps'), { withFileTypes: true }))
|
|
66
|
+
.filter((entry) => entry.isDirectory())
|
|
67
|
+
.map((entry) => entry.name);
|
|
68
|
+
}
|
|
69
|
+
catch (error) {
|
|
70
|
+
if (isMissing(error))
|
|
71
|
+
return [];
|
|
72
|
+
throw new CliError('READ_FAILED', 'Cannot read src/apps', { cause: error });
|
|
73
|
+
}
|
|
74
|
+
const problems = [];
|
|
75
|
+
for (const app of apps) {
|
|
76
|
+
const views = `src/apps/${app}/views`;
|
|
77
|
+
if (await exists(join(root, views, 'app.js')))
|
|
78
|
+
continue; // an Inertia app: Vite takes its styles
|
|
79
|
+
for (const file of await files(root, views)) {
|
|
80
|
+
if (!ASSET.test(file))
|
|
81
|
+
continue;
|
|
82
|
+
const inPublic = `src/apps/${app}/public/${file.slice(views.length + 1)}`;
|
|
83
|
+
problems.push({
|
|
84
|
+
rule: RULE,
|
|
85
|
+
severity: 'warning',
|
|
86
|
+
file,
|
|
87
|
+
line: 0,
|
|
88
|
+
message: `${file} is a browser file in the views of app ${app}: views hold templates`,
|
|
89
|
+
fix: `move it to ${inPublic} and link /assets/${file.slice(views.length + 1)} (@dokebi/app serves src/apps/${app}/public/ at /assets/ through @dokebi/static: npx dokebi add @dokebi/static); never read it with a route of your own`,
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
debug('check: asset rule done', { apps: apps.length, problems: problems.length });
|
|
94
|
+
return problems;
|
|
95
|
+
}
|
|
96
|
+
export const ASSET_ROUTE_FIX = 'put the file in src/apps/<app>/public/ and link /assets/<path> (a file a package ships, such as a font of @dokebi/font-noto-kr, is copied there with its licence file): @dokebi/app serves that folder on the app hosts through @dokebi/static (npx dokebi add @dokebi/static) with a validator, 304 and Range, where this route sends the whole file on every request. A file the route makes per request (a generated image) takes // dokebi-check: allow asset-route <reason>';
|
|
97
|
+
/** Calls that read a file from disk. */
|
|
98
|
+
const READS = new Set(['createReadStream', 'readFile', 'readFileSync']);
|
|
99
|
+
/** asset-route: a GET (or HEAD) route for a browser file whose handler reads the file itself. */
|
|
100
|
+
export function assetRouteRules(file, kinds, report) {
|
|
101
|
+
// Most files name no browser file: skip the walk (a fixed path need not read the file here,
|
|
102
|
+
// R1 Codex T3 read it in a controller).
|
|
103
|
+
if (!/\.(?:css|png|jpe?g|gif|webp|avif|svg|ico|woff2?|ttf|otf)['"`]/i.test(file.text))
|
|
104
|
+
return;
|
|
105
|
+
const text = (node) => file.text.slice(node.getStart(), node.end);
|
|
106
|
+
const literal = (node) => node?.text;
|
|
107
|
+
/** A route path written as one literal without parameters: a fixed file. */
|
|
108
|
+
const fixed = (node) => (node.kind === kinds.StringLiteral || node.kind === kinds.NoSubstitutionTemplateLiteral) &&
|
|
109
|
+
!/[:*]/.test(literal(node) ?? ':');
|
|
110
|
+
/** The last literal text of a route path: a string, a template or a `+` chain. */
|
|
111
|
+
const tail = (node) => {
|
|
112
|
+
if (!node)
|
|
113
|
+
return undefined;
|
|
114
|
+
if (node.kind === kinds.StringLiteral || node.kind === kinds.NoSubstitutionTemplateLiteral)
|
|
115
|
+
return literal(node);
|
|
116
|
+
if (node.kind === kinds.TemplateExpression)
|
|
117
|
+
return literal(field(nodes(node, 'templateSpans').at(-1) ?? node, 'literal'));
|
|
118
|
+
if (node.kind === kinds.ParenthesizedExpression)
|
|
119
|
+
return tail(field(node, 'expression'));
|
|
120
|
+
if (node.kind === kinds.BinaryExpression) {
|
|
121
|
+
const operator = field(node, 'operatorToken');
|
|
122
|
+
return operator && text(operator) === '+' ? tail(field(node, 'right')) : undefined;
|
|
123
|
+
}
|
|
124
|
+
return undefined;
|
|
125
|
+
};
|
|
126
|
+
/** The name a call is made by: `readFile(…)` or `fs.readFile(…)`. */
|
|
127
|
+
const called = (node) => {
|
|
128
|
+
const callee = field(node, 'expression');
|
|
129
|
+
if (callee?.kind === kinds.Identifier)
|
|
130
|
+
return text(callee);
|
|
131
|
+
const name = callee?.kind === kinds.PropertyAccessExpression ? field(callee, 'name') : undefined;
|
|
132
|
+
return name ? text(name) : undefined;
|
|
133
|
+
};
|
|
134
|
+
const readsFile = (node) => {
|
|
135
|
+
let found = false;
|
|
136
|
+
const visit = (child) => {
|
|
137
|
+
if (child.kind === kinds.CallExpression && READS.has(called(child) ?? ''))
|
|
138
|
+
found = true;
|
|
139
|
+
else
|
|
140
|
+
child.forEachChild(visit);
|
|
141
|
+
return found || undefined;
|
|
142
|
+
};
|
|
143
|
+
node.forEachChild(visit);
|
|
144
|
+
return found;
|
|
145
|
+
};
|
|
146
|
+
const visit = (node) => {
|
|
147
|
+
if (node.kind === kinds.CallExpression &&
|
|
148
|
+
field(node, 'expression')?.kind === kinds.PropertyAccessExpression &&
|
|
149
|
+
/^(?:get|head)$/.test(called(node) ?? '')) {
|
|
150
|
+
const path = nodes(node, 'arguments')[0];
|
|
151
|
+
const last = tail(path);
|
|
152
|
+
if (path && last && ASSET.test(last)) {
|
|
153
|
+
if (fixed(path))
|
|
154
|
+
report(node, `The route ${text(path)} answers a browser file of its own: a file of public/ gets a validator, 304 and Range, and a route that sends it does not (R1, R6)`, ASSET_ROUTE_FIX);
|
|
155
|
+
else if (readsFile(node))
|
|
156
|
+
report(node, `The route ${text(path)} sends a browser file it reads itself: no validator, no 304 and no Range, the whole file on every request (R6)`, ASSET_ROUTE_FIX);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
node.forEachChild(visit);
|
|
160
|
+
return undefined;
|
|
161
|
+
};
|
|
162
|
+
file.forEachChild(visit);
|
|
163
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { VirtualSource } from './typescript.js';
|
|
2
|
+
/** The suffix of a virtual source; never a real file and never what an import resolves to. */
|
|
3
|
+
export declare const VIRTUAL_SUFFIX = ".dokebi-check";
|
|
4
|
+
/**
|
|
5
|
+
* The script of a .vue file as one TypeScript or JavaScript text of the same length: the
|
|
6
|
+
* template, the styles and the tags blanked (newlines kept). Undefined without a script block.
|
|
7
|
+
*/
|
|
8
|
+
export declare function vueScript(text: string): {
|
|
9
|
+
text: string;
|
|
10
|
+
typescript: boolean;
|
|
11
|
+
} | undefined;
|
|
12
|
+
/** The virtual source of one file (project-relative `path`), or undefined when there is none. */
|
|
13
|
+
export declare function browserSource(root: string, path: string, text: string): VirtualSource | undefined;
|
|
14
|
+
/** Is `path` (project-relative) browser code check reads: a .vue file, or .js/.mjs in views/? */
|
|
15
|
+
export declare function isBrowserPath(path: string): boolean;
|
|
16
|
+
/**
|
|
17
|
+
* The browser code under `directory` (default `src/`) of a project: .vue files and the .js/.mjs
|
|
18
|
+
* under views/ (node_modules, dist, .vite, .git and the .tmp scratch folder are not walked).
|
|
19
|
+
*/
|
|
20
|
+
export declare function browserSources(root: string, skip?: (path: string) => boolean, directory?: string): Promise<VirtualSource[]>;
|