@dokebi/cli 0.4.2 → 0.4.27
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 +279 -57
- package/README.md +35 -10
- package/catalog.json +2520 -258
- 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 +638 -37
- 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 +15 -0
- package/dist/check/shared-state-rules.js +117 -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 +381 -36
- 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-secrets.d.ts +46 -0
- package/dist/config-secrets.js +187 -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/deploy-scripts.d.ts +4 -0
- package/dist/deploy-scripts.js +361 -0
- package/dist/deploy-templates.d.ts +46 -0
- package/dist/deploy-templates.js +379 -0
- package/dist/deploy.d.ts +46 -0
- package/dist/deploy.js +592 -0
- 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 +20 -2
- package/dist/install-module.js +45 -8
- 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 +17 -2
- 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 +40 -4
- package/dist/npm-client.js +152 -15
- 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 +195 -16
- 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/production-doctor.d.ts +3 -0
- package/dist/production-doctor.js +176 -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 +497 -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 +3006 -333
- 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/remote-verify.d.ts +31 -0
- package/dist/remote-verify.js +204 -0
- package/dist/server.d.ts +43 -4
- package/dist/server.js +601 -52
- 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 +47 -0
- package/dist/transaction.js +241 -7
- package/dist/verify-tests.d.ts +98 -0
- package/dist/verify-tests.js +449 -0
- package/dist/verify.d.ts +103 -3
- package/dist/verify.js +272 -21
- 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,563 @@
|
|
|
1
|
+
// The recipe notices that state a fact of the project (D-0137): whether CSRF protection, Seal,
|
|
2
|
+
// HTML form bodies or a session are on, where local mail goes, whether the site's realms still
|
|
3
|
+
// deny every login, whether its job queue is provisioned. AI-M27: verify relayed the site notice
|
|
4
|
+
// "the generated auth extensions deny every login until connected to your user store" after the
|
|
5
|
+
// builder had connected them; the recipe had recorded the sentence as fixed text.
|
|
6
|
+
//
|
|
7
|
+
// Such a notice is recorded as a token, `{fact:<id> <args>}`, never as the sentence. The recipe's
|
|
8
|
+
// report (before or right after applying) says what the plan does (`plannedText`); verify says
|
|
9
|
+
// what it finds in the project then (`projectText`): dokebi.yaml, the installed plugins, the
|
|
10
|
+
// files the recipe wrote, whether the app it started bound its queues, the behaviour of the auth
|
|
11
|
+
// extension's hooks (site-auth.ts). When the fact still holds, the sentence is the one the recipe
|
|
12
|
+
// always said; when it changed, the sentence says what was found; when the CLI cannot tell, it
|
|
13
|
+
// says so and where to look. A record written before D-0137 holds the old sentences: each one
|
|
14
|
+
// the old code generated is recognized by being exactly that text (`recordedFact`) and read the
|
|
15
|
+
// same way.
|
|
16
|
+
import { readFile } from 'node:fs/promises';
|
|
17
|
+
import { join } from 'node:path';
|
|
18
|
+
import { parseProjectConfig, readProjectSettings } from './configuration.js';
|
|
19
|
+
import { debug, describeError, isMissing } from './diagnostics.js';
|
|
20
|
+
import { CliError } from './errors.js';
|
|
21
|
+
import { exists, parseObject, securePath } from './filesystem.js';
|
|
22
|
+
import { findHost } from './manifest.js';
|
|
23
|
+
import { readState } from './project-state.js';
|
|
24
|
+
import { EXTENSION_FILE } from './recipe-login.js';
|
|
25
|
+
import { MAIL_COMPOSE, mailComposePorts } from './recipe-mail.js';
|
|
26
|
+
import { LEGACY_SITE_WORKER_PATH, SITE_LOGIN_HINT, SITE_NOTICE, SITE_WORKER_PATH, siteRoutesPath, } from './recipe-site.js';
|
|
27
|
+
const CSRF_MODES = [
|
|
28
|
+
'inertia',
|
|
29
|
+
'form-body',
|
|
30
|
+
'site-api',
|
|
31
|
+
'site-inertia',
|
|
32
|
+
'site-ejs',
|
|
33
|
+
];
|
|
34
|
+
/** The recorded form of a fact notice. */
|
|
35
|
+
export function factToken(fact) {
|
|
36
|
+
const args = fact.id === 'csrf'
|
|
37
|
+
? [fact.mode]
|
|
38
|
+
: fact.id === 'seal'
|
|
39
|
+
? [fact.view, ...fact.domains]
|
|
40
|
+
: fact.id === 'ssr-seal'
|
|
41
|
+
? [...fact.domains]
|
|
42
|
+
: [];
|
|
43
|
+
return `{fact:${[fact.id, ...args].join(' ')}}`;
|
|
44
|
+
}
|
|
45
|
+
const TOKEN = /^\{fact:([a-z-]+)((?: [^\s{}]+)*)\}$/;
|
|
46
|
+
/** The fact of a recorded token; undefined for any other notice (or a token this CLI does not know). */
|
|
47
|
+
export function parseFact(notice) {
|
|
48
|
+
const match = TOKEN.exec(notice);
|
|
49
|
+
if (!match)
|
|
50
|
+
return undefined;
|
|
51
|
+
const id = match[1];
|
|
52
|
+
const args = (match[2] ?? '').split(' ').filter(Boolean);
|
|
53
|
+
if ((id === 'site' || id === 'mail' || id === 'session') && args.length === 0)
|
|
54
|
+
return { id };
|
|
55
|
+
if (id === 'csrf' && args.length === 1 && CSRF_MODES.includes(args[0]))
|
|
56
|
+
return { id, mode: args[0] };
|
|
57
|
+
if (id === 'seal' && (args[0] === 'ejs' || args[0] === 'inertia') && args.length > 1)
|
|
58
|
+
return { id, view: args[0], domains: args.slice(1) };
|
|
59
|
+
if (id === 'ssr-seal' && args.length > 0)
|
|
60
|
+
return { id, domains: args };
|
|
61
|
+
debug('Recipe record: unknown fact token kept as recorded', { notice });
|
|
62
|
+
return undefined;
|
|
63
|
+
}
|
|
64
|
+
// --- The sentences (the planned ones are what recipes have always said) ---------------------
|
|
65
|
+
/**
|
|
66
|
+
* How the forms of a plan send the CSRF token (D-0076 §6): @dokebi/csrf is installed for every
|
|
67
|
+
* write form, so the report says what each view does instead of "no CSRF protection".
|
|
68
|
+
*/
|
|
69
|
+
export function csrfNotice(inertia) {
|
|
70
|
+
return inertia
|
|
71
|
+
? 'CSRF protection is on (@dokebi/csrf): Inertia forms need no code (its client sends the XSRF-TOKEN cookie as the X-XSRF-TOKEN header), and fetch sends the same header.'
|
|
72
|
+
: 'CSRF protection is on (@dokebi/csrf): every <form method="post"> writes <%- data.csrf.field %>, and fetch sends the XSRF-TOKEN cookie in the X-XSRF-TOKEN header.';
|
|
73
|
+
}
|
|
74
|
+
/** What the notice of a plan with HTML form bodies says before the CSRF sentence. */
|
|
75
|
+
const FORM_BODY_ON = 'HTML forms are on (dokebi-web.formBody=true).';
|
|
76
|
+
const SITE_CSRF = 'CSRF protection is on (@dokebi/csrf): unsafe application API calls go through sealedFetch, which sends the current XSRF-TOKEN cookie as X-XSRF-TOKEN by itself (@dokebi/seal 0.4.2); no adapter of your own.';
|
|
77
|
+
/** The same without @dokebi/seal (AI-M47 Codex removed it): there is no sealedFetch adapter. */
|
|
78
|
+
const SITE_CSRF_PLAIN = 'CSRF protection is on (@dokebi/csrf): unsafe application API calls must send the current XSRF-TOKEN cookie as X-XSRF-TOKEN (with fetch; @dokebi/seal is not installed now, so there is no sealedFetch adapter).';
|
|
79
|
+
const SITE_INERTIA_CSRF = 'Inertia forms send the CSRF header automatically.';
|
|
80
|
+
const SITE_EJS_CSRF = 'EJS write forms include <%- data.csrf.field %>; dokebi-web.formBody is on (sealed forms are form bodies).';
|
|
81
|
+
const CSRF_OFF = 'CSRF protection is off: @dokebi/csrf is not installed now (the recipe installed it), so no unsafe request is checked for its token.';
|
|
82
|
+
const formBodyOff = (file) => `HTML form posts are refused now (415 FORM_BODY_DISABLED): dokebi-web.formBody is not true in ${file} (the recipe turned it on).`;
|
|
83
|
+
/** The seal head an EJS layout writes in its `<head>` (the shipped template-ejs layout's words). */
|
|
84
|
+
export const SEAL_HEAD = "<%- data.seal?.head ?? '' %>";
|
|
85
|
+
/**
|
|
86
|
+
* The browser module the generated Inertia entry (`views/app.js`) imports on sealed pages: it
|
|
87
|
+
* imports `@dokebi/seal/http`, which `dokebiInertia()` of the generated vite.config.ts maps to this
|
|
88
|
+
* served file (D-0062 addendum 2026-10-20).
|
|
89
|
+
*/
|
|
90
|
+
export const SEAL_HTTP_MODULE = '/dokebi/seal/http.mjs';
|
|
91
|
+
/**
|
|
92
|
+
* What the user must know about the sealed browser hosts of a plan (D-0092 §1, D-0062): the
|
|
93
|
+
* consumer convention seals every browser app. EJS forms keep working without per-form code;
|
|
94
|
+
* Inertia pages and visits go sealed through the bootstrap of the generated entry.
|
|
95
|
+
*/
|
|
96
|
+
export function sealNotice(domains, view = 'ejs') {
|
|
97
|
+
const head = `Seal is on for ${domains.join(', ')} (@dokebi/seal, dokebi-app.domains.<host>.seal: true): `;
|
|
98
|
+
return view === 'inertia'
|
|
99
|
+
? `${head}the first page's data-page and every Inertia visit (page props, form posts, validation errors) go sealed, and a plain JSON body is refused (415 SEAL_REQUIRED). Each app's views/app.js awaits sealInertia(http) from @dokebi/seal/http before createInertiaApp; keep it. dokebiInertia() of vite.config.ts makes every import of @dokebi/seal/http load the served ${SEAL_HTTP_MODULE} (one instance), so REST calls in Vue pages import sealedFetch from '@dokebi/seal/http' as usual.`
|
|
100
|
+
: `${head}the shared EJS layout writes ${SEAL_HEAD} in its <head>, which loads @dokebi/seal/forms; it sends every <form method="post"> sealed with no per-form code (the handler reads the same request.body; dokebi-web.formBody is on). Pages stay plain HTML; REST calls use sealedFetch.`;
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Why the browser hosts of a server-rendered Inertia plan stay unsealed (D-0062 §5): SSR writes
|
|
104
|
+
* the page props into the first HTML as plain text, so @dokebi/template-inertia refuses to start
|
|
105
|
+
* an SSR scope on a seal host (SEAL_SSR_CONFLICT). The user chose SSR; the convention still asks
|
|
106
|
+
* for Seal, so the user decides between the two.
|
|
107
|
+
*/
|
|
108
|
+
export function ssrSealNotice(domains) {
|
|
109
|
+
return `${legacySsrSealNotice(domains)} ${READ_ONLY_HOSTS}`;
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Which hosts gain from seal (the @dokebi/seal guide, audit 2026-10-25): a host that only shows
|
|
113
|
+
* public pages needs no seal, so SSR there is no conflict to ask the user about.
|
|
114
|
+
*/
|
|
115
|
+
export const READ_ONLY_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.";
|
|
116
|
+
/** The SSR notice as records before D-0137 hold it (recognised by `recordedFact`). */
|
|
117
|
+
function legacySsrSealNotice(domains) {
|
|
118
|
+
return `Seal is off for ${domains.join(', ')}: server rendering (view inertia-vue-ssr) writes the page props into the first HTML as plain text, so a sealed host refuses to start it (SEAL_SSR_CONFLICT). Browser apps are sealed by convention: confirm with the user that these hosts stay unsealed, or switch to inertia-vue (drop ssr from the app's inertia settings in app.ts, npx dokebi add @dokebi/seal, then seal: true on these domains in dokebi.yaml) and rebuild.`;
|
|
119
|
+
}
|
|
120
|
+
/** What @dokebi/session would add to an EJS blog without it (AI-M05 F3). */
|
|
121
|
+
export const SESSION_OFFER = 'npx dokebi add @dokebi/session makes a refused form go back to its page with the typed values and the field messages';
|
|
122
|
+
/** The notice of an EJS plan without @dokebi/session (AI-M05 F3: not installed behind the user's back). */
|
|
123
|
+
export const SESSION_NOTICE = 'Without @dokebi/session (not part of these choices), a form with invalid input shows an error page instead of coming back with what was typed; say if you want it added (npx dokebi add @dokebi/session).';
|
|
124
|
+
const SESSION_ADDED = '@dokebi/session is installed now (it was not part of the recipe choices): a form with invalid input comes back to its page with what was typed and the field messages.';
|
|
125
|
+
const MAIL_TOOLS = `Run docker compose -f ${MAIL_COMPOSE} port mailpit 8025 for its loopback inbox address, npx dokebi mail preview welcome --json for a template preview, and docker compose -f ${MAIL_COMPOSE} down to stop it (add -v only to delete queued mail).`;
|
|
126
|
+
const RECOVERY = 'Password recovery needs an email address mapped by the auth extension';
|
|
127
|
+
/** The mail notice as recipes before D-0137 recorded it (the first admin part was fixed text). */
|
|
128
|
+
const RECORDED_MAIL = `Local mail stays in Mailpit. ${MAIL_TOOLS} ${RECOVERY}; the first admin login is not an email address.`;
|
|
129
|
+
/** The site's job worker queue and its example handler (recipe-site.ts). */
|
|
130
|
+
const SITE_QUEUE = 'siteWorker';
|
|
131
|
+
const NATS_PLACEHOLDER = 'configure-me.invalid';
|
|
132
|
+
const LOGIN_SCREENS = 'Login screens are app code: the site scaffold wrote none (its GET /login of each app answers a JSON hint until you replace it).';
|
|
133
|
+
/** The auth default the site recipe gives an app (recipe-site.ts site-config). */
|
|
134
|
+
const plannedDefault = (app) => (app === 'web' ? 'optional' : 'required');
|
|
135
|
+
const list = (items) => items.length > 1 ? `${items.slice(0, -1).join(', ')} and ${items.at(-1)}` : (items[0] ?? '');
|
|
136
|
+
const realmWord = (items) => `${items.length > 1 ? 'realms' : 'realm'} ${list(items)}`;
|
|
137
|
+
const loginWord = (value) => value === 'required' || value === 'optional'
|
|
138
|
+
? `login ${value} by default`
|
|
139
|
+
: `default ${JSON.stringify(value ?? null)}`;
|
|
140
|
+
/** The notice of a fact as the plan makes it (the recipe's report). */
|
|
141
|
+
export function plannedText(fact, plan) {
|
|
142
|
+
switch (fact.id) {
|
|
143
|
+
case 'csrf':
|
|
144
|
+
return fact.mode === 'inertia'
|
|
145
|
+
? csrfNotice(true)
|
|
146
|
+
: fact.mode === 'form-body'
|
|
147
|
+
? `${FORM_BODY_ON} ${csrfNotice(false)}`
|
|
148
|
+
: fact.mode === 'site-api'
|
|
149
|
+
? SITE_CSRF
|
|
150
|
+
: fact.mode === 'site-inertia'
|
|
151
|
+
? SITE_INERTIA_CSRF
|
|
152
|
+
: SITE_EJS_CSRF;
|
|
153
|
+
case 'seal':
|
|
154
|
+
return sealNotice(fact.domains, fact.view);
|
|
155
|
+
case 'ssr-seal':
|
|
156
|
+
return ssrSealNotice(fact.domains);
|
|
157
|
+
case 'session':
|
|
158
|
+
return SESSION_NOTICE;
|
|
159
|
+
case 'mail':
|
|
160
|
+
return `Local mail stays in Mailpit. ${MAIL_TOOLS} ${RECOVERY}${plan.adminLogin
|
|
161
|
+
? `; the first admin signs in with the login name ${plan.adminLogin}${plan.adminLogin.includes('@') ? '' : ', not an email address'}.`
|
|
162
|
+
: '.'}`;
|
|
163
|
+
case 'site': {
|
|
164
|
+
const site = plan.site;
|
|
165
|
+
const parts = [];
|
|
166
|
+
if (site?.apps.length) {
|
|
167
|
+
parts.push(`Site apps and their auth realms (options.dokebi-auth.apps of dokebi.yaml): ${site.apps
|
|
168
|
+
.map((app) => `${app} → realm ${site.realms[app]}, ${loginWord(plannedDefault(app))}`)
|
|
169
|
+
.join('; ')}.`);
|
|
170
|
+
const realms = [...new Set(site.apps.map((app) => site.realms[app]))];
|
|
171
|
+
parts.push(`User lookup is app code: ${EXTENSION_FILE} gets the site scaffold's stub for ${realmWord(realms)}, whose findByIdentity and findById find nobody, so every login is denied until they read your user store.`, LOGIN_SCREENS);
|
|
172
|
+
}
|
|
173
|
+
if (site?.worker === 'job')
|
|
174
|
+
parts.push(`A job worker needs a configured NATS connection (options.dokebi-nats.connections.${SITE_QUEUE}.servers) and a provisioned ${SITE_QUEUE} queue (npx dokebi-job-provision --queue ${SITE_QUEUE}) before verify.`);
|
|
175
|
+
if (site?.worker === 'thread')
|
|
176
|
+
parts.push('The thread worker (@dokebi/worker) handles CPU tasks in this process.');
|
|
177
|
+
return parts.join(' ');
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
/** The settings file a notice names (dokebi.yaml unless DOKEBI_CONFIG named another). */
|
|
182
|
+
const settingsFileOf = (facts) => facts.settingsFile ?? 'dokebi.yaml';
|
|
183
|
+
const record = (value) => value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
184
|
+
? value
|
|
185
|
+
: undefined;
|
|
186
|
+
/**
|
|
187
|
+
* Reads the installed plugins and the settings file (DOKEBI_CONFIG, else dokebi.yaml) for `projectText`; undefined (and a debug line with
|
|
188
|
+
* the error) when either cannot be read, and the notices then say the CLI could not read them.
|
|
189
|
+
*/
|
|
190
|
+
export async function readProjectFacts(root, input) {
|
|
191
|
+
try {
|
|
192
|
+
const state = readState(parseObject(await readFile(join(root, '.dokebi/state.json'), 'utf8'), 'state'));
|
|
193
|
+
// M50: the settings file the app reads (DOKEBI_CONFIG, else dokebi.yaml), named in the notices.
|
|
194
|
+
const settings = await readProjectSettings(root);
|
|
195
|
+
const options = parseProjectConfig(settings.source).options;
|
|
196
|
+
const host = findHost(state.plugins, 'apps');
|
|
197
|
+
return {
|
|
198
|
+
root,
|
|
199
|
+
installed: new Set(state.plugins.map((plugin) => plugin.id)),
|
|
200
|
+
options,
|
|
201
|
+
...(settings.target.fromEnvironment ? { settingsFile: settings.target.file } : {}),
|
|
202
|
+
...(host ? { appsHost: host.id } : {}),
|
|
203
|
+
...input,
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
catch (error) {
|
|
207
|
+
debug('Recipe facts: the project could not be read', { error: describeError(error) });
|
|
208
|
+
return undefined;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
/** The notice of a fact when the project could not be read: that, and where to look. */
|
|
212
|
+
export function unreadText(fact, recipe) {
|
|
213
|
+
const topic = {
|
|
214
|
+
site: [
|
|
215
|
+
"the site's realms, user store and worker",
|
|
216
|
+
`SITE.md, ${EXTENSION_FILE} and dokebi.yaml`,
|
|
217
|
+
],
|
|
218
|
+
csrf: ['CSRF protection', 'npx dokebi plugins list and dokebi.yaml'],
|
|
219
|
+
seal: ['Seal', 'npx dokebi plugins list and the domains in dokebi.yaml'],
|
|
220
|
+
'ssr-seal': ['Seal', 'npx dokebi plugins list and the domains in dokebi.yaml'],
|
|
221
|
+
mail: ['local mail', `options.dokebi-mail of dokebi.yaml and ${MAIL_COMPOSE}`],
|
|
222
|
+
session: ['@dokebi/session', 'npx dokebi plugins list'],
|
|
223
|
+
};
|
|
224
|
+
const [what, where] = topic[fact.id];
|
|
225
|
+
return `The CLI could not read the project for the ${recipe} recipe's note on ${what}: look at ${where}.`;
|
|
226
|
+
}
|
|
227
|
+
/** The notice of a fact as the project is now; '' when nothing is left to say (the line is dropped). */
|
|
228
|
+
export async function projectText(fact, entry, facts) {
|
|
229
|
+
const has = (id) => facts.installed.has(id);
|
|
230
|
+
const formBody = record(facts.options['dokebi-web'])?.formBody === true;
|
|
231
|
+
switch (fact.id) {
|
|
232
|
+
case 'csrf': {
|
|
233
|
+
if (!has('dokebi-csrf'))
|
|
234
|
+
return fact.mode === 'site-inertia' ? '' : CSRF_OFF;
|
|
235
|
+
if (fact.mode === 'form-body')
|
|
236
|
+
return `${formBody ? FORM_BODY_ON : formBodyOff(settingsFileOf(facts))} ${csrfNotice(false)}`;
|
|
237
|
+
if (fact.mode === 'site-ejs' && !formBody)
|
|
238
|
+
return `EJS write forms include <%- data.csrf.field %>; ${formBodyOff(settingsFileOf(facts))}`;
|
|
239
|
+
if (fact.mode === 'site-api' && !has('dokebi-seal'))
|
|
240
|
+
return SITE_CSRF_PLAIN;
|
|
241
|
+
return plannedText(fact, { recipe: entry.recipe });
|
|
242
|
+
}
|
|
243
|
+
case 'seal':
|
|
244
|
+
case 'ssr-seal': {
|
|
245
|
+
const host = facts.appsHost ?? 'dokebi-app';
|
|
246
|
+
const domains = record(record(facts.options[host])?.domains) ?? {};
|
|
247
|
+
const sealedNow = (domain) => has('dokebi-seal') && record(domains[domain])?.seal === true;
|
|
248
|
+
// The hosts the project serves now: a host the recipe wrote that `app link`/`unlink`
|
|
249
|
+
// renamed is gone, and its new name is what to report (AI-M42: the note called renamed,
|
|
250
|
+
// sealed hosts "off"). With no domains left, the recipe's hosts are reported as off.
|
|
251
|
+
const current = Object.keys(domains);
|
|
252
|
+
const gone = fact.domains.filter((domain) => !(domain in domains));
|
|
253
|
+
// Only when a host the recipe wrote is gone are the hosts it does not know its new names
|
|
254
|
+
// (another fact, such as ssr-seal for one app, keeps to its own hosts otherwise).
|
|
255
|
+
const hosts = current.length === 0 || gone.length === 0
|
|
256
|
+
? fact.domains
|
|
257
|
+
: [
|
|
258
|
+
...fact.domains.filter((domain) => domain in domains),
|
|
259
|
+
...current.filter((domain) => !fact.domains.includes(domain)),
|
|
260
|
+
];
|
|
261
|
+
const on = hosts.filter(sealedNow);
|
|
262
|
+
const off = hosts.filter((domain) => !sealedNow(domain));
|
|
263
|
+
const why = has('dokebi-seal')
|
|
264
|
+
? `${host}.domains.<host>.seal is not true in ${settingsFileOf(facts)}`
|
|
265
|
+
: '@dokebi/seal is not installed now';
|
|
266
|
+
if (fact.id === 'seal')
|
|
267
|
+
return [
|
|
268
|
+
...(on.length ? [sealNotice(on, fact.view)] : []),
|
|
269
|
+
...(off.length ? [`Seal is off now for ${off.join(', ')} (${why}).`] : []),
|
|
270
|
+
].join(' ');
|
|
271
|
+
return [
|
|
272
|
+
...(off.length ? [ssrSealNotice(off)] : []),
|
|
273
|
+
...(on.length
|
|
274
|
+
? [`Seal is on now for ${on.join(', ')} (${host}.domains.<host>.seal: true).`]
|
|
275
|
+
: []),
|
|
276
|
+
].join(' ');
|
|
277
|
+
}
|
|
278
|
+
case 'session':
|
|
279
|
+
return has('dokebi-session') ? SESSION_ADDED : SESSION_NOTICE;
|
|
280
|
+
case 'mail':
|
|
281
|
+
return mailText(facts, entry);
|
|
282
|
+
case 'site':
|
|
283
|
+
return siteText(facts, entry);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
/** Where local mail goes and how the first admin signs in. */
|
|
287
|
+
async function mailText(facts, entry) {
|
|
288
|
+
const mail = record(facts.options['dokebi-mail']);
|
|
289
|
+
const transport = record(mail?.transport);
|
|
290
|
+
let where;
|
|
291
|
+
if (!facts.installed.has('dokebi-mail') || !mail)
|
|
292
|
+
where = `Mail is not configured now: @dokebi/mail or options.dokebi-mail of ${settingsFileOf(facts)} is gone (the recipe set up local Mailpit).`;
|
|
293
|
+
else {
|
|
294
|
+
const driver = typeof transport?.driver === 'string' ? transport.driver : undefined;
|
|
295
|
+
const host = typeof transport?.host === 'string' ? transport.host : undefined;
|
|
296
|
+
const port = typeof transport?.port === 'number' ? transport.port : undefined;
|
|
297
|
+
const loopback = host !== undefined && ['localhost', '127.0.0.1', '::1'].includes(host);
|
|
298
|
+
if (driver !== 'smtp' || !loopback || port === undefined)
|
|
299
|
+
where = `Mail does not go to the local Mailpit now: options.dokebi-mail.transport uses ${driver ?? 'no driver'}${host && !loopback ? ` at ${host}` : ''} (look there for where mail goes).`;
|
|
300
|
+
else {
|
|
301
|
+
const smtp = await composeSmtpPort(facts.root);
|
|
302
|
+
where =
|
|
303
|
+
smtp.port === port
|
|
304
|
+
? `Local mail stays in Mailpit (options.dokebi-mail.transport: smtp ${host}:${port}, the mailpit service of ${MAIL_COMPOSE}). ${MAIL_TOOLS}`
|
|
305
|
+
: smtp.port !== undefined
|
|
306
|
+
? `Mail goes to smtp ${host}:${port} (options.dokebi-mail.transport), not to the Mailpit of ${MAIL_COMPOSE} (port ${smtp.port}): look at both.`
|
|
307
|
+
: `Mail goes to smtp ${host}:${port} (options.dokebi-mail.transport); the CLI could not tell whether that is the Mailpit of ${MAIL_COMPOSE} (${smtp.reason}): look at that file.`;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
const admin = entry.answers.admin?.value === 'login' ? await adminPart(facts) : '';
|
|
311
|
+
return `${where} ${RECOVERY}${admin ? `; ${admin}` : ''}.`;
|
|
312
|
+
}
|
|
313
|
+
/** How the first admin signs in, for the recovery sentence of the mail notice. */
|
|
314
|
+
async function adminPart(facts) {
|
|
315
|
+
const found = await facts.firstAdmin();
|
|
316
|
+
if (!found.section || !found.identity)
|
|
317
|
+
return 'the CLI could not find how the first admin signs in (see the admin login line)';
|
|
318
|
+
const at = `options.${found.section}.${found.identity.key}`;
|
|
319
|
+
return found.identity.kind === 'email'
|
|
320
|
+
? `the first admin signs in with the email address ${found.identity.value} (${at})`
|
|
321
|
+
: `the first admin signs in with the login name ${found.identity.value} (${at})${found.identity.value.includes('@') ? '' : ', not an email address'}`;
|
|
322
|
+
}
|
|
323
|
+
/** The SMTP port compose.mail.yaml publishes for Mailpit, or why it is not known. */
|
|
324
|
+
async function composeSmtpPort(root) {
|
|
325
|
+
let source;
|
|
326
|
+
try {
|
|
327
|
+
source = await readFile(join(root, MAIL_COMPOSE), 'utf8');
|
|
328
|
+
}
|
|
329
|
+
catch (error) {
|
|
330
|
+
if (isMissing(error))
|
|
331
|
+
return { reason: `${MAIL_COMPOSE} does not exist` };
|
|
332
|
+
debug('Recipe facts: the mail compose file could not be read', { error: describeError(error) });
|
|
333
|
+
return { reason: `${MAIL_COMPOSE} could not be read` };
|
|
334
|
+
}
|
|
335
|
+
try {
|
|
336
|
+
const ports = mailComposePorts(root, source);
|
|
337
|
+
return ports ? { port: ports.smtp } : { reason: `${MAIL_COMPOSE} names no published port` };
|
|
338
|
+
}
|
|
339
|
+
catch (error) {
|
|
340
|
+
if (!(error instanceof CliError))
|
|
341
|
+
throw error;
|
|
342
|
+
debug('Recipe facts: the mail compose file is not the recipe template', { code: error.code });
|
|
343
|
+
// An edited file (a service removed, a comment added) still says where Mailpit's SMTP is:
|
|
344
|
+
// its published loopback port for 1025 (AI-M44: the note said "could not tell" for a file
|
|
345
|
+
// that published it plainly).
|
|
346
|
+
const smtp = [...source.matchAll(/127\.0\.0\.1:(\d+):1025\b/g)].map((match) => Number(match[1]));
|
|
347
|
+
if (smtp.length === 1 && smtp[0] >= 1 && smtp[0] <= 65535)
|
|
348
|
+
return { port: smtp[0] };
|
|
349
|
+
return { reason: `${MAIL_COMPOSE} was edited` };
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
/** The site notice: realms and their login default, the user store, the worker. */
|
|
353
|
+
async function siteText(facts, entry) {
|
|
354
|
+
const appsAnswer = entry.answers.apps?.value ?? 'none';
|
|
355
|
+
const apps = appsAnswer === 'none' ? [] : appsAnswer.split(',').filter(Boolean);
|
|
356
|
+
const worker = entry.answers.worker?.value ?? 'none';
|
|
357
|
+
const parts = [];
|
|
358
|
+
if (apps.length) {
|
|
359
|
+
if (!facts.installed.has('dokebi-auth'))
|
|
360
|
+
parts.push(`@dokebi/auth is not installed now: the site apps ${list(apps)} have no login (the site recipe installed it).`);
|
|
361
|
+
else {
|
|
362
|
+
const auth = record(facts.options['dokebi-auth']) ?? {};
|
|
363
|
+
const configured = record(auth.apps) ?? {};
|
|
364
|
+
const realmOf = (app) => {
|
|
365
|
+
const value = record(configured[app])?.realm ?? auth.realm ?? 'default';
|
|
366
|
+
return typeof value === 'string' ? value : undefined;
|
|
367
|
+
};
|
|
368
|
+
parts.push(`Site apps and their auth realms (options.dokebi-auth.apps of ${settingsFileOf(facts)}): ${apps
|
|
369
|
+
.map((app) => {
|
|
370
|
+
const settings = record(configured[app]);
|
|
371
|
+
if (!settings)
|
|
372
|
+
return `${app} → no entry`;
|
|
373
|
+
return `${app} → realm ${realmOf(app) ?? '(not a name)'}, ${loginWord(settings.default ?? auth.default)}`;
|
|
374
|
+
})
|
|
375
|
+
.join('; ')}.`);
|
|
376
|
+
const realms = [
|
|
377
|
+
...new Set(apps.flatMap((app) => (configured[app] ? (realmOf(app) ?? []) : []))),
|
|
378
|
+
];
|
|
379
|
+
if (realms.length)
|
|
380
|
+
parts.push(userStoreText(await facts.userStores(realms)));
|
|
381
|
+
const hinted = await scaffoldLogins(facts.root, apps);
|
|
382
|
+
if (hinted.length === apps.length)
|
|
383
|
+
parts.push(LOGIN_SCREENS);
|
|
384
|
+
else if (hinted.length)
|
|
385
|
+
parts.push(`Login screens are app code: GET /login of ${list(hinted)} still answers the site scaffold's JSON hint (${hinted.map(siteRoutesPath).join(', ')}) until you replace it.`);
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
if (worker === 'job')
|
|
389
|
+
parts.push(await jobText(facts));
|
|
390
|
+
if (worker === 'thread')
|
|
391
|
+
parts.push(facts.installed.has('dokebi-worker')
|
|
392
|
+
? 'The thread worker (@dokebi/worker) handles CPU tasks in this process.'
|
|
393
|
+
: 'The thread worker the site recipe added (@dokebi/worker) is not installed now.');
|
|
394
|
+
return parts.join(' ');
|
|
395
|
+
}
|
|
396
|
+
/**
|
|
397
|
+
* The site apps whose routes file still has the scaffold's GET /login hint. A file that is gone or
|
|
398
|
+
* unreadable no longer serves the hint (the app replaced or moved its login), so it is not listed.
|
|
399
|
+
*/
|
|
400
|
+
async function scaffoldLogins(root, apps) {
|
|
401
|
+
const hinted = [];
|
|
402
|
+
for (const app of apps) {
|
|
403
|
+
const path = siteRoutesPath(app);
|
|
404
|
+
try {
|
|
405
|
+
if ((await readFile(join(root, path), 'utf8')).includes(SITE_LOGIN_HINT))
|
|
406
|
+
hinted.push(app);
|
|
407
|
+
}
|
|
408
|
+
catch (error) {
|
|
409
|
+
debug('Recipe facts: a site routes file could not be read', {
|
|
410
|
+
path,
|
|
411
|
+
error: describeError(error),
|
|
412
|
+
});
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
return hinted;
|
|
416
|
+
}
|
|
417
|
+
/** What the probe of the realms' hooks found (site-auth.ts), in words. */
|
|
418
|
+
export function userStoreText(probe) {
|
|
419
|
+
if (!probe.ok)
|
|
420
|
+
return `The CLI could not tell whether ${EXTENSION_FILE} still denies every login (${probe.code}: ${probe.message}): look at findByIdentity and findById of each realm there.`;
|
|
421
|
+
const stub = [];
|
|
422
|
+
const app = [];
|
|
423
|
+
const sentences = [];
|
|
424
|
+
const missing = [];
|
|
425
|
+
for (const realm of probe.realms) {
|
|
426
|
+
if (!realm.defined) {
|
|
427
|
+
missing.push(realm.realm);
|
|
428
|
+
continue;
|
|
429
|
+
}
|
|
430
|
+
if (realm.failure || !realm.findByIdentity || !realm.findById) {
|
|
431
|
+
sentences.push(`The CLI could not tell for realm ${realm.realm} (its class could not be made: ${realm.failure ?? 'no hooks'}): look at it in ${EXTENSION_FILE}.`);
|
|
432
|
+
continue;
|
|
433
|
+
}
|
|
434
|
+
const hooks = [
|
|
435
|
+
['findByIdentity', realm.findByIdentity.state],
|
|
436
|
+
['findById', realm.findById.state],
|
|
437
|
+
];
|
|
438
|
+
const unimplemented = hooks.filter(([, state]) => state === 'unimplemented').map(([h]) => h);
|
|
439
|
+
const stubbed = hooks.filter(([, state]) => state === 'stub').map(([h]) => h);
|
|
440
|
+
if (unimplemented.length)
|
|
441
|
+
sentences.push(`Realm ${realm.realm}: ${list(unimplemented)} ${unimplemented.length > 1 ? 'are' : 'is'} not implemented in ${EXTENSION_FILE} (EXTENSION_REQUIRED).`);
|
|
442
|
+
else if (stubbed.length === 2)
|
|
443
|
+
stub.push(realm.realm);
|
|
444
|
+
else if (stubbed.length === 1)
|
|
445
|
+
sentences.push(`Realm ${realm.realm}: ${stubbed[0]} is still the site scaffold's stub (it finds nobody) while ${stubbed[0] === 'findById' ? 'findByIdentity' : 'findById'} is app code, so logins there do not hold until both read your user store.`);
|
|
446
|
+
else
|
|
447
|
+
app.push(realm.realm);
|
|
448
|
+
}
|
|
449
|
+
return [
|
|
450
|
+
...(stub.length
|
|
451
|
+
? [
|
|
452
|
+
`User lookup is app code: ${EXTENSION_FILE} is still the site scaffold's stub for ${realmWord(stub)}, whose findByIdentity and findById find nobody, so every login there is denied until they read your user store.`,
|
|
453
|
+
]
|
|
454
|
+
: []),
|
|
455
|
+
...(app.length
|
|
456
|
+
? [
|
|
457
|
+
`User lookup of ${realmWord(app)} is connected: findByIdentity and findById in ${EXTENSION_FILE} are app code, no longer the site scaffold's deny-every-login stub (whether they find the right users is for the app's tests).`,
|
|
458
|
+
]
|
|
459
|
+
: []),
|
|
460
|
+
...sentences,
|
|
461
|
+
...(missing.length
|
|
462
|
+
? [
|
|
463
|
+
`${missing.length > 1 ? 'Realms' : 'Realm'} ${list(missing)} ${missing.length > 1 ? 'are' : 'is'} not defined in ${EXTENSION_FILE} (export default is the default realm, export const realms the others).`,
|
|
464
|
+
]
|
|
465
|
+
: []),
|
|
466
|
+
].join(' ');
|
|
467
|
+
}
|
|
468
|
+
/** The site's job worker: whether its queue is configured and was bound by the started app. */
|
|
469
|
+
async function jobText(facts) {
|
|
470
|
+
if (!facts.installed.has('dokebi-job'))
|
|
471
|
+
return `@dokebi/job is not installed now: the site recipe's job worker has no ${SITE_QUEUE} queue.`;
|
|
472
|
+
const queue = record(record(record(facts.options['dokebi-job'])?.queues)?.[SITE_QUEUE]);
|
|
473
|
+
if (!queue)
|
|
474
|
+
return `options.dokebi-job.queues of ${settingsFileOf(facts)} has no ${SITE_QUEUE} queue now (the site recipe configured it).`;
|
|
475
|
+
const connection = typeof queue.connection === 'string' ? queue.connection : undefined;
|
|
476
|
+
let file;
|
|
477
|
+
for (const path of [SITE_WORKER_PATH, LEGACY_SITE_WORKER_PATH])
|
|
478
|
+
if (!file && (await exists(await securePath(facts.root, path))))
|
|
479
|
+
file = path;
|
|
480
|
+
const handler = file
|
|
481
|
+
? ` Its example handler site.example is in ${file}.`
|
|
482
|
+
: ` The worker file the recipe wrote (${SITE_WORKER_PATH}) is not there now.`;
|
|
483
|
+
if (facts.started)
|
|
484
|
+
return `The job worker's ${SITE_QUEUE} queue (options.dokebi-job.queues.${SITE_QUEUE}, NATS connection ${connection ?? '(not named)'}) was bound when verify started the app: its NATS server answered and its streams and consumer are provisioned.${handler}`;
|
|
485
|
+
const servers = connection
|
|
486
|
+
? record(record(record(facts.options['dokebi-nats'])?.connections)?.[connection])?.servers
|
|
487
|
+
: undefined;
|
|
488
|
+
const placeholder = Array.isArray(servers) && servers.some((item) => String(item).includes(NATS_PLACEHOLDER));
|
|
489
|
+
return `${placeholder
|
|
490
|
+
? `options.dokebi-nats.connections.${connection}.servers still holds the placeholder nats://${NATS_PLACEHOLDER}:4222: write your NATS server there (private ${settingsFileOf(facts)}), then`
|
|
491
|
+
: `The job worker's NATS connection ${connection ?? '(not named)'} is configured; the app was not started, so the CLI could not tell whether the ${SITE_QUEUE} queue is provisioned:`} run npx dokebi-job-provision --queue ${SITE_QUEUE} before npx dokebi verify.${handler}`;
|
|
492
|
+
}
|
|
493
|
+
// --- Records written before D-0137 ----------------------------------------------------------
|
|
494
|
+
/** The sentences of the old notices, each with its fact (the exact text the old code wrote). */
|
|
495
|
+
const RECORDED = new Map([
|
|
496
|
+
[SITE_NOTICE, { id: 'site' }],
|
|
497
|
+
[SITE_CSRF, { id: 'csrf', mode: 'site-api' }],
|
|
498
|
+
[SITE_INERTIA_CSRF, { id: 'csrf', mode: 'site-inertia' }],
|
|
499
|
+
[SITE_EJS_CSRF, { id: 'csrf', mode: 'site-ejs' }],
|
|
500
|
+
[csrfNotice(true), { id: 'csrf', mode: 'inertia' }],
|
|
501
|
+
[`${FORM_BODY_ON} ${csrfNotice(false)}`, { id: 'csrf', mode: 'form-body' }],
|
|
502
|
+
[RECORDED_MAIL, { id: 'mail' }],
|
|
503
|
+
[SESSION_NOTICE, { id: 'session' }],
|
|
504
|
+
]);
|
|
505
|
+
const RECORDED_SEAL = /^Seal is on for (\S+(?:, \S+)*) \(@dokebi\/seal, /;
|
|
506
|
+
const RECORDED_SSR_SEAL = /^Seal is off for (\S+(?:, \S+)*): server rendering /;
|
|
507
|
+
/** The fact of a notice a record before D-0137 holds as a sentence; undefined for any other. */
|
|
508
|
+
export function recordedFact(notice) {
|
|
509
|
+
const known = RECORDED.get(notice);
|
|
510
|
+
if (known)
|
|
511
|
+
return known;
|
|
512
|
+
const sealed = RECORDED_SEAL.exec(notice);
|
|
513
|
+
if (sealed?.[1]) {
|
|
514
|
+
const domains = sealed[1].split(', ');
|
|
515
|
+
for (const view of ['ejs', 'inertia'])
|
|
516
|
+
if (sealNotice(domains, view) === notice)
|
|
517
|
+
return { id: 'seal', view, domains };
|
|
518
|
+
}
|
|
519
|
+
const unsealed = RECORDED_SSR_SEAL.exec(notice);
|
|
520
|
+
if (unsealed?.[1]) {
|
|
521
|
+
const domains = unsealed[1].split(', ');
|
|
522
|
+
if (ssrSealNotice(domains) === notice || legacySsrSealNotice(domains) === notice)
|
|
523
|
+
return { id: 'ssr-seal', domains };
|
|
524
|
+
}
|
|
525
|
+
return undefined;
|
|
526
|
+
}
|
|
527
|
+
/** The fact of a recorded notice: a token, or an old sentence; undefined for any other notice. */
|
|
528
|
+
export function factOf(notice) {
|
|
529
|
+
return notice.startsWith('{fact:') ? parseFact(notice) : recordedFact(notice);
|
|
530
|
+
}
|
|
531
|
+
/** The key of a fact notice of a recipe in the map verify fills (`factTexts`). */
|
|
532
|
+
export function factKey(recipe, fact) {
|
|
533
|
+
return `${recipe}\n${factToken(fact)}`;
|
|
534
|
+
}
|
|
535
|
+
/** Every fact notice of the recipes as the project is now (verify), by `factKey`. */
|
|
536
|
+
export async function factTexts(recipes, facts) {
|
|
537
|
+
const texts = new Map();
|
|
538
|
+
for (const entry of recipes)
|
|
539
|
+
for (const notice of entry.notices) {
|
|
540
|
+
const fact = factOf(notice);
|
|
541
|
+
if (!fact)
|
|
542
|
+
continue;
|
|
543
|
+
const key = factKey(entry.recipe, fact);
|
|
544
|
+
if (texts.has(key))
|
|
545
|
+
continue;
|
|
546
|
+
let text = unreadText(fact, entry.recipe);
|
|
547
|
+
if (facts)
|
|
548
|
+
try {
|
|
549
|
+
text = await projectText(fact, entry, facts);
|
|
550
|
+
}
|
|
551
|
+
catch (error) {
|
|
552
|
+
// A file that cannot be read (not a missing one): the notice says it could not tell.
|
|
553
|
+
debug('Recipe facts: a notice could not be read from the project', {
|
|
554
|
+
recipe: entry.recipe,
|
|
555
|
+
fact: fact.id,
|
|
556
|
+
error: describeError(error),
|
|
557
|
+
});
|
|
558
|
+
}
|
|
559
|
+
texts.set(key, text);
|
|
560
|
+
}
|
|
561
|
+
debug('Recipe facts read from the project', { notices: texts.size, read: facts !== undefined });
|
|
562
|
+
return texts;
|
|
563
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { LEGACY_LOGIN_NAMES, LOGIN_NAMES, type LoginNames, USERS_TABLE } from './recipe-members.js';
|
|
2
|
+
export type LoginDialect = 'postgres' | 'mysql' | 'mariadb' | 'mongodb';
|
|
3
|
+
export type LoginView = 'ejs' | 'inertia';
|
|
4
|
+
/**
|
|
5
|
+
* The dokebi.yaml section the first admin comes from (no plugin owns it). D-0115: named for what
|
|
6
|
+
* it holds (the first admin, like the seed first-admin.ts), not for one kind of app.
|
|
7
|
+
*/
|
|
8
|
+
export declare const ADMIN_SECTION = "first-admin";
|
|
9
|
+
/** The name older recipes wrote (until D-0115); such projects keep it and keep working. */
|
|
10
|
+
export declare const LEGACY_ADMIN_SECTION = "blog-admin";
|
|
11
|
+
/** Both names the CLI reads (config show, check, verify). */
|
|
12
|
+
export declare const ADMIN_SECTIONS: readonly string[];
|
|
13
|
+
/** The key of the generated initial password in that section (never printed). */
|
|
14
|
+
export declare const ADMIN_PASSWORD_KEY = "initialPassword";
|
|
15
|
+
/** The login name of the first admin. */
|
|
16
|
+
export declare const ADMIN_LOGIN = "admin";
|
|
17
|
+
/** The table (collection) of the app's users, and the names of its code (D-0115). */
|
|
18
|
+
export { LEGACY_LOGIN_NAMES, LOGIN_NAMES, type LoginNames, USERS_TABLE };
|
|
19
|
+
/** The one sentence of every failed login (never tell an unknown name from a wrong password). */
|
|
20
|
+
export declare const LOGIN_FAILED = "Invalid login or password.";
|
|
21
|
+
/** Where a login goes, and the auth `homePath` (a signed-in user on the login page goes there). */
|
|
22
|
+
export declare const ADMIN_PATH = "/admin";
|
|
23
|
+
export declare const EXTENSION_FILE = "src/infra/auth/extension.ts";
|
|
24
|
+
export declare const SEED_FILE = "src/infra/db/seeds/main/first-admin.ts";
|
|
25
|
+
/**
|
|
26
|
+
* The section the recipe writes the first admin into (D-0115): the older name when the project
|
|
27
|
+
* already uses it (its section in dokebi.yaml, or the seed an older recipe wrote reads it), so a
|
|
28
|
+
* recipe applied again never splits one admin over two sections; otherwise ADMIN_SECTION.
|
|
29
|
+
*/
|
|
30
|
+
export declare function recipeAdminSection(options: Record<string, unknown>, seedText?: string): string;
|
|
31
|
+
/**
|
|
32
|
+
* The names of the login code for this project (D-0115): an older recipe's blog_authors when the
|
|
33
|
+
* project has its migration, or a model, extension or seed that names that table; otherwise
|
|
34
|
+
* LOGIN_NAMES. So migrationPath and a recipe applied again never make a second table or a
|
|
35
|
+
* migration that conflicts with the one the project has.
|
|
36
|
+
*/
|
|
37
|
+
export declare function recipeLoginNames(migrations: readonly string[], sources?: readonly string[]): LoginNames;
|
|
38
|
+
/** The project files that tell an older recipe's login code (recipeLoginNames). */
|
|
39
|
+
export declare const LOGIN_NAME_SOURCES: readonly string[];
|
|
40
|
+
export interface LoginFile {
|
|
41
|
+
readonly path: string;
|
|
42
|
+
readonly content: string;
|
|
43
|
+
/** The file `add @dokebi/auth` created from its template: replaced while still untouched. */
|
|
44
|
+
readonly replaceTemplate?: boolean;
|
|
45
|
+
}
|
|
46
|
+
/** `20261017093000`: the UTC time a new migration file is named after (dokebi-migrate make). */
|
|
47
|
+
export declare function migrationStamp(date: Date): string;
|
|
48
|
+
/** The migration file this project already has, else a new name for `now`. */
|
|
49
|
+
export declare function migrationPath(existing: readonly string[], now: Date, names?: LoginNames): string;
|
|
50
|
+
export declare const MIGRATIONS_DIRECTORY = "src/infra/db/migrations/main";
|
|
51
|
+
/** Every file of the admin login for a dialect and view (the migration path decided by the caller). */
|
|
52
|
+
export declare function loginFiles(dialect: LoginDialect, view: LoginView, migrationFile: string, mail?: boolean,
|
|
53
|
+
/** The section of dokebi.yaml the seed reads (recipeAdminSection). */
|
|
54
|
+
section?: string,
|
|
55
|
+
/** The table and code names (recipeLoginNames, D-0115). */
|
|
56
|
+
names?: LoginNames): LoginFile[];
|
|
57
|
+
/**
|
|
58
|
+
* src/apps/main/routes.ts with the login routes added: an import after the last import and a call
|
|
59
|
+
* at the top of `routes(route, …)`. Unchanged when already there; RECIPE_FILE_CONFLICT when the
|
|
60
|
+
* file has no `export function routes(<router>…)` to add it to.
|
|
61
|
+
*/
|
|
62
|
+
export declare function withLoginRoutes(text: string): string;
|
|
63
|
+
export declare const ROUTES_FILE = "src/apps/main/routes.ts";
|