@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
package/dist/recipe.js
CHANGED
|
@@ -3,22 +3,61 @@
|
|
|
3
3
|
// package that provides it. Without every answer nothing is installed (ANSWERS_REQUIRED). With
|
|
4
4
|
// answers each step calls the existing transactional command, in order, skipping steps that are
|
|
5
5
|
// already satisfied and stopping at the first failure with the steps that completed.
|
|
6
|
-
import {
|
|
7
|
-
import { readFile } from 'node:fs/promises';
|
|
6
|
+
import { readdir, readFile } from 'node:fs/promises';
|
|
8
7
|
import { join, resolve } from 'node:path';
|
|
9
8
|
import { parse } from 'yaml';
|
|
9
|
+
import { ANSWER_SOURCES, ASK_AND_WAIT } from './capability-guide.js';
|
|
10
10
|
import { HOST_CAPABILITY, loadCatalog, providersOf, } from './catalog.js';
|
|
11
|
-
import {
|
|
11
|
+
import { runDevCompose } from './dev-compose.js';
|
|
12
|
+
import { AUTO_DOMAIN, devHost, readProjectHostLabel } from './dev-host.js';
|
|
12
13
|
import { COMPOSE_FILE, commandOnPath, composeServiceRunning, DB_SERVICE, dockerAvailable, freePortAfter, portInUse, portQuestion, URL_PLACEHOLDER_PREFIX, } from './dev-infra.js';
|
|
13
14
|
import { debug, describeError, isMissing } from './diagnostics.js';
|
|
14
15
|
import { CliError } from './errors.js';
|
|
15
16
|
import { exists } from './filesystem.js';
|
|
16
|
-
import {
|
|
17
|
+
import { describeFirstAdmin, firstAdminPhrase } from './first-admin.js';
|
|
17
18
|
import { initFolder } from './project.js';
|
|
18
|
-
import {
|
|
19
|
+
import { csrfNotice, factOf, factToken, plannedText, SEAL_HEAD, SEAL_HTTP_MODULE, SESSION_OFFER, } from './recipe-facts.js';
|
|
20
|
+
import { ADMIN_LOGIN, ADMIN_PASSWORD_KEY, ADMIN_PATH, EXTENSION_FILE, LOGIN_NAME_SOURCES, loginFiles, MIGRATIONS_DIRECTORY, migrationPath, ROUTES_FILE, recipeAdminSection, recipeLoginNames, SEED_FILE, withLoginRoutes, } from './recipe-login.js';
|
|
21
|
+
import { checkMailCompose, mailSteps } from './recipe-mail.js';
|
|
22
|
+
import { APP_URL_PLACEHOLDER, FIRST_ADMIN_PLACEHOLDER, LOGIN_PAGES, NO_SCREENS_BUILT, saveAppliedRecipe, } from './recipe-record.js';
|
|
23
|
+
import { checkSite, sealedSiteApp, siteDefinition, siteSteps } from './recipe-site.js';
|
|
24
|
+
import { provider, shell, view } from './recipe-step.js';
|
|
19
25
|
import * as server from './server.js';
|
|
20
|
-
|
|
21
|
-
|
|
26
|
+
import { AI_REASON_MAX, aiReasonFits } from './view-choice.js';
|
|
27
|
+
// The notice sentences live with the facts they state (D-0137); the names stay importable here.
|
|
28
|
+
export { csrfNotice, SEAL_HEAD, SEAL_HTTP_MODULE, sealNotice, ssrSealNotice, } from './recipe-facts.js';
|
|
29
|
+
/**
|
|
30
|
+
* D-0098: a recipe that builds no browser screens (`screens: false`, the api recipe) answers the
|
|
31
|
+
* view with `none`. It is not a question (the recipe is the choice) and not a value the recipe
|
|
32
|
+
* fixes: who chose no screens is recorded like an answer, the user's unless the AI names `view`
|
|
33
|
+
* in `--ai-chose` (or the user left every choice to it), and verify's tellUser says it. Its app
|
|
34
|
+
* skeleton (the first app, which `add @dokebi/app` writes) has an EJS home page, but no screens
|
|
35
|
+
* for people. An app added later with `app add` inherits view none (no views, a JSON API) unless
|
|
36
|
+
* it says `--view ejs` or `--inertia` (M50).
|
|
37
|
+
*/
|
|
38
|
+
export const SCREENS = 'view';
|
|
39
|
+
/** The view answer of a recipe without browser screens. */
|
|
40
|
+
export const NO_SCREENS = 'none';
|
|
41
|
+
/** The recipes that ask the view (blog, site): where a service people use in a browser goes. */
|
|
42
|
+
export function screenRecipes(catalog) {
|
|
43
|
+
return catalog.recipes
|
|
44
|
+
.filter((recipe) => recipe.questions.includes('view') && !recipe.fixed?.view)
|
|
45
|
+
.map((recipe) => recipe.name);
|
|
46
|
+
}
|
|
47
|
+
/** The words of a list: `blog or site`. */
|
|
48
|
+
function either(names) {
|
|
49
|
+
return names.length > 1
|
|
50
|
+
? `${names.slice(0, -1).join(', ')} or ${names.at(-1)}`
|
|
51
|
+
: (names[0] ?? '');
|
|
52
|
+
}
|
|
53
|
+
/** What a recipe without screens tells the AI before and after planning (D-0098). */
|
|
54
|
+
export function noScreensNotice(recipe, browserRecipes) {
|
|
55
|
+
return `The ${recipe} recipe builds no browser screens: a JSON API whose clients are other programs (Bearer tokens, server to server). When people use the service in a browser (sign up, browse, press buttons, read lists), it is a browser app: use the ${either(browserRecipes)} recipe instead (they ask the view; default Inertia + Vue). No screens (API only) is a product choice: unless the user or the request said so, it is yours, allowed only when the user left it to you; then pass --ai-chose ${SCREENS} with --ai-reason ${SCREENS}=<why> and tell the user in the first lines of your final message.`;
|
|
56
|
+
}
|
|
57
|
+
/** The recorded notice of a recipe without screens: verify's tellUser tells the user (D-0098). */
|
|
58
|
+
export function noScreensRecord(recipe, browserRecipes) {
|
|
59
|
+
return `No browser screens (${recipe} recipe): ${NO_SCREENS_BUILT} Browser screens come from the ${either(browserRecipes)} recipe (default view Inertia + Vue).`;
|
|
60
|
+
}
|
|
22
61
|
/** `dokebi recipe list`. */
|
|
23
62
|
export async function listRecipes() {
|
|
24
63
|
const catalog = await loadCatalog();
|
|
@@ -57,6 +96,19 @@ function applies(choice, answers) {
|
|
|
57
96
|
}
|
|
58
97
|
return true;
|
|
59
98
|
}
|
|
99
|
+
/** An option's own `when` (admin login needs a database): unmet when an answer contradicts it. */
|
|
100
|
+
function unmet(option, answers, flagOf) {
|
|
101
|
+
for (const [id, rule] of Object.entries(option.when ?? {})) {
|
|
102
|
+
const value = answers[id];
|
|
103
|
+
if (value === undefined)
|
|
104
|
+
continue;
|
|
105
|
+
if (rule.startsWith('!') ? value === rule.slice(1) : value !== rule)
|
|
106
|
+
return rule.startsWith('!')
|
|
107
|
+
? `Not available with ${flagOf(id)} ${value}`
|
|
108
|
+
: `Only available with ${flagOf(id)} ${rule}`;
|
|
109
|
+
}
|
|
110
|
+
return undefined;
|
|
111
|
+
}
|
|
60
112
|
function variantOf(catalog, capability) {
|
|
61
113
|
if (!capability)
|
|
62
114
|
return undefined;
|
|
@@ -66,15 +118,6 @@ function variantOf(catalog, capability) {
|
|
|
66
118
|
return variant;
|
|
67
119
|
return undefined;
|
|
68
120
|
}
|
|
69
|
-
/** The plugin package that provides a capability (the recipe never names packages). */
|
|
70
|
-
function provider(catalog, capability) {
|
|
71
|
-
const found = providersOf(catalog, capability).find((item) => item.kind === 'plugin');
|
|
72
|
-
if (!found)
|
|
73
|
-
throw new CliError('CATALOG_INVALID', `No plugin provides ${capability}`, {
|
|
74
|
-
details: { capability },
|
|
75
|
-
});
|
|
76
|
-
return found.package;
|
|
77
|
-
}
|
|
78
121
|
/** The devInfra docker note when `docker` is not on PATH (the default stays; the user decides). */
|
|
79
122
|
export const DOCKER_NOT_FOUND = 'docker was not found on PATH: install and start Docker before applying with docker, or choose url (a database server you already have)';
|
|
80
123
|
/** Question list with options adjusted to the answers given so far (docker per db variant). */
|
|
@@ -83,11 +126,16 @@ function questions(catalog, recipe, answers, dockerOnPath = () => commandOnPath(
|
|
|
83
126
|
const db = catalog.choices
|
|
84
127
|
.find((item) => item.id === 'db')
|
|
85
128
|
?.options.find((option) => option.value === values.db);
|
|
129
|
+
const flagOf = (id) => choiceOf(catalog, id).flag;
|
|
86
130
|
return recipe.questions
|
|
87
131
|
.filter((id) => !recipe.fixed?.[id])
|
|
88
132
|
.map((id) => {
|
|
89
133
|
const choice = choiceOf(catalog, id);
|
|
90
134
|
const options = choice.options.map((option) => {
|
|
135
|
+
// An option another answer rules out (admin login with db none) is shown unavailable.
|
|
136
|
+
const why = option.available ? unmet(option, values, flagOf) : undefined;
|
|
137
|
+
if (why)
|
|
138
|
+
return { ...option, available: false, why };
|
|
91
139
|
if (id !== 'devInfra' || option.value !== 'docker' || !db?.capability)
|
|
92
140
|
return option;
|
|
93
141
|
const docker = variantOf(catalog, db.capability)?.devInfra?.docker;
|
|
@@ -110,6 +158,7 @@ function questions(catalog, recipe, answers, dockerOnPath = () => commandOnPath(
|
|
|
110
158
|
flag: choice.flag,
|
|
111
159
|
question: choice.question,
|
|
112
160
|
options: shown,
|
|
161
|
+
...(choice.input ? { input: choice.input } : {}),
|
|
113
162
|
default: choice.default,
|
|
114
163
|
defaultReason: missingDocker && choice.default === 'docker'
|
|
115
164
|
? `${choice.defaultReason}. Note: ${DOCKER_NOT_FOUND}`
|
|
@@ -119,7 +168,104 @@ function questions(catalog, recipe, answers, dockerOnPath = () => commandOnPath(
|
|
|
119
168
|
};
|
|
120
169
|
});
|
|
121
170
|
}
|
|
122
|
-
/**
|
|
171
|
+
/** Where the answers came from: `--answers-from`, else `--defaults` alone; undefined: not said. */
|
|
172
|
+
function answerSource(request) {
|
|
173
|
+
return request.answersFrom ?? (request.defaults ? 'defaults' : undefined);
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* The given answers the agent chose (D-0095 `--ai-chose`) and its reasons (`--ai-reason`),
|
|
177
|
+
* checked against the recipe: an id it does not ask, one it fixes, one without its flag, and a
|
|
178
|
+
* reason for an id that is not an AI choice are refused before anything is planned.
|
|
179
|
+
*/
|
|
180
|
+
function aiChoices(catalog, recipe, request) {
|
|
181
|
+
// D-0098: a recipe without screens records view = none, which --ai-chose can name (view, --view).
|
|
182
|
+
const screens = recipe.screens === false ? [SCREENS] : [];
|
|
183
|
+
const asked = [...recipe.questions.filter((id) => !recipe.fixed?.[id]), ...screens];
|
|
184
|
+
// A question id (devInfra) or its flag name (dev-infra, --dev-infra) names the same question.
|
|
185
|
+
// The recipe's fixed answers count too, so naming one is AI_CHOICE_FIXED, not unknown.
|
|
186
|
+
const ids = [...recipe.questions, ...Object.keys(recipe.fixed ?? {})];
|
|
187
|
+
const idOf = (name) => {
|
|
188
|
+
if (ids.includes(name) || screens.includes(name))
|
|
189
|
+
return name;
|
|
190
|
+
const flag = `--${name.replace(/^-+/, '')}`;
|
|
191
|
+
return [...ids, ...screens].find((id) => choiceOf(catalog, id).flag === flag);
|
|
192
|
+
};
|
|
193
|
+
const known = (name, flag) => {
|
|
194
|
+
const id = idOf(name);
|
|
195
|
+
if (!id)
|
|
196
|
+
throw new CliError('AI_CHOICE_UNKNOWN', `The ${recipe.name} recipe does not ask ${name}`, {
|
|
197
|
+
details: {
|
|
198
|
+
recipe: recipe.name,
|
|
199
|
+
[flag === '--ai-chose' ? 'aiChose' : 'aiReason']: name,
|
|
200
|
+
questions: asked,
|
|
201
|
+
fix: `name only question ids of this recipe in ${flag}: ${asked.join(', ')}`,
|
|
202
|
+
},
|
|
203
|
+
phase: 'arguments',
|
|
204
|
+
});
|
|
205
|
+
return id;
|
|
206
|
+
};
|
|
207
|
+
const chose = new Set();
|
|
208
|
+
for (const name of request.aiChose ?? []) {
|
|
209
|
+
const id = known(name, '--ai-chose');
|
|
210
|
+
const fixed = recipe.fixed?.[id];
|
|
211
|
+
if (fixed !== undefined)
|
|
212
|
+
throw new CliError('AI_CHOICE_FIXED', `The ${recipe.name} recipe fixes ${id} (${fixed}); it is not a choice the AI made`, {
|
|
213
|
+
details: {
|
|
214
|
+
recipe: recipe.name,
|
|
215
|
+
question: id,
|
|
216
|
+
value: fixed,
|
|
217
|
+
fix: `drop ${id} from --ai-chose`,
|
|
218
|
+
},
|
|
219
|
+
phase: 'arguments',
|
|
220
|
+
});
|
|
221
|
+
if (!screens.includes(id) && request.answers[id] === undefined) {
|
|
222
|
+
const flag = choiceOf(catalog, id).flag;
|
|
223
|
+
throw new CliError('AI_CHOICE_WITHOUT_ANSWER', `--ai-chose names ${id}, but ${flag} is not given`, {
|
|
224
|
+
details: {
|
|
225
|
+
recipe: recipe.name,
|
|
226
|
+
question: id,
|
|
227
|
+
fix: `pass ${flag} <your choice> with --ai-chose ${id}, or drop ${id} from --ai-chose and add --defaults to take the default (only when the user left the choice to you)`,
|
|
228
|
+
},
|
|
229
|
+
phase: 'arguments',
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
chose.add(id);
|
|
233
|
+
}
|
|
234
|
+
const reasons = new Map();
|
|
235
|
+
for (const [name, text] of Object.entries(request.aiReasons ?? {})) {
|
|
236
|
+
const id = known(name, '--ai-reason');
|
|
237
|
+
if (!chose.has(id))
|
|
238
|
+
throw new CliError('AI_REASON_WITHOUT_CHOICE', `--ai-reason ${id} is given, but ${id} is not in --ai-chose`, {
|
|
239
|
+
details: {
|
|
240
|
+
recipe: recipe.name,
|
|
241
|
+
question: id,
|
|
242
|
+
aiChose: [...chose],
|
|
243
|
+
fix: `add ${id} to --ai-chose if you chose it because the user left it to you; a reason for the user's own answer is not recorded`,
|
|
244
|
+
},
|
|
245
|
+
phase: 'arguments',
|
|
246
|
+
});
|
|
247
|
+
const reason = text.trim();
|
|
248
|
+
if (!aiReasonFits(reason))
|
|
249
|
+
throw new CliError('INVALID_AI_REASON', `--ai-reason ${id} must be one line of 1 to ${AI_REASON_MAX} characters`, {
|
|
250
|
+
details: {
|
|
251
|
+
recipe: recipe.name,
|
|
252
|
+
question: id,
|
|
253
|
+
length: reason.length,
|
|
254
|
+
fix: `give one short line the user reads: why you chose ${request.answers[id] ?? 'none'} for ${id}`,
|
|
255
|
+
},
|
|
256
|
+
phase: 'arguments',
|
|
257
|
+
});
|
|
258
|
+
reasons.set(id, reason);
|
|
259
|
+
}
|
|
260
|
+
if (chose.size)
|
|
261
|
+
debug('recipe: answers the AI chose', { ids: [...chose], reasons: reasons.size });
|
|
262
|
+
return { chose, reasons };
|
|
263
|
+
}
|
|
264
|
+
/**
|
|
265
|
+
* Collects answers from flags, recipe-fixed values and (when asked) defaults; validates them.
|
|
266
|
+
* Each given answer is the user's, or the agent's when it is in `aiChose` or when the user left
|
|
267
|
+
* every choice to the agent (`--defaults` alone, D-0095).
|
|
268
|
+
*/
|
|
123
269
|
function answer(catalog, recipe, request) {
|
|
124
270
|
const answers = {};
|
|
125
271
|
for (const id of Object.keys(request.answers))
|
|
@@ -131,6 +277,14 @@ function answer(catalog, recipe, request) {
|
|
|
131
277
|
},
|
|
132
278
|
phase: 'arguments',
|
|
133
279
|
});
|
|
280
|
+
const { chose, reasons } = aiChoices(catalog, recipe, request);
|
|
281
|
+
const delegatedAll = answerSource(request) === 'defaults';
|
|
282
|
+
const fromFlag = (id, value) => {
|
|
283
|
+
if (!chose.has(id) && !delegatedAll)
|
|
284
|
+
return { value, answeredBy: 'user' };
|
|
285
|
+
const reason = reasons.get(id);
|
|
286
|
+
return { value, answeredBy: 'ai', ...(reason ? { aiReason: reason } : {}) };
|
|
287
|
+
};
|
|
134
288
|
for (const [id, value] of Object.entries(recipe.fixed ?? {}))
|
|
135
289
|
answers[id] = { value, answeredBy: 'recipe' };
|
|
136
290
|
for (const id of recipe.questions) {
|
|
@@ -140,6 +294,14 @@ function answer(catalog, recipe, request) {
|
|
|
140
294
|
const given = request.answers[id];
|
|
141
295
|
if (given === undefined)
|
|
142
296
|
continue;
|
|
297
|
+
if (choice.input === 'text') {
|
|
298
|
+
if (!given.trim() || given.length > 2048)
|
|
299
|
+
throw new CliError('INVALID_ANSWER', `${choice.flag} requires at most 2048 characters`, {
|
|
300
|
+
phase: 'arguments',
|
|
301
|
+
});
|
|
302
|
+
answers[id] = fromFlag(id, given);
|
|
303
|
+
continue;
|
|
304
|
+
}
|
|
143
305
|
const option = choice.options.find((item) => item.value === given);
|
|
144
306
|
if (!option)
|
|
145
307
|
throw new CliError('INVALID_ANSWER', `${choice.flag} ${given} is not an option`, {
|
|
@@ -151,7 +313,7 @@ function answer(catalog, recipe, request) {
|
|
|
151
313
|
details: { question: id, option: given, why: option.why ?? null },
|
|
152
314
|
phase: 'arguments',
|
|
153
315
|
});
|
|
154
|
-
answers[id] =
|
|
316
|
+
answers[id] = fromFlag(id, given);
|
|
155
317
|
}
|
|
156
318
|
const missing = [];
|
|
157
319
|
const values = () => Object.fromEntries(Object.entries(answers).map(([id, item]) => [id, item.value]));
|
|
@@ -161,7 +323,7 @@ function answer(catalog, recipe, request) {
|
|
|
161
323
|
const choice = choiceOf(catalog, id);
|
|
162
324
|
if (!applies(choice, values()))
|
|
163
325
|
continue;
|
|
164
|
-
if (request.defaults || request
|
|
326
|
+
if (request.defaults || answerSource(request) === 'defaults')
|
|
165
327
|
answers[id] = {
|
|
166
328
|
value: choice.default,
|
|
167
329
|
answeredBy: 'default',
|
|
@@ -174,27 +336,26 @@ function answer(catalog, recipe, request) {
|
|
|
174
336
|
for (const id of Object.keys(answers))
|
|
175
337
|
if (!applies(choiceOf(catalog, id), values()))
|
|
176
338
|
delete answers[id];
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
339
|
+
// An answer another answer rules out (--admin login with --db none, D-0076 §6).
|
|
340
|
+
for (const [id, item] of Object.entries(answers)) {
|
|
341
|
+
const choice = choiceOf(catalog, id);
|
|
342
|
+
const option = choice.options.find((entry) => entry.value === item.value);
|
|
343
|
+
const why = option && unmet(option, values(), (other) => choiceOf(catalog, other).flag);
|
|
344
|
+
if (why)
|
|
345
|
+
throw new CliError('OPTION_NOT_APPLICABLE', `${choice.flag} ${item.value}: ${why}`, {
|
|
346
|
+
details: {
|
|
347
|
+
question: id,
|
|
348
|
+
option: item.value,
|
|
349
|
+
when: option?.when ?? null,
|
|
350
|
+
fix: `ask the user to choose another ${choice.flag} or change the answer it depends on`,
|
|
351
|
+
},
|
|
352
|
+
phase: 'arguments',
|
|
353
|
+
});
|
|
188
354
|
}
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
packages: new Set((state.plugins ?? []).flatMap((plugin) => plugin.packageName ?? [])),
|
|
194
|
-
apps: state.apps ?? [],
|
|
195
|
-
modules: state.modules ?? [],
|
|
196
|
-
options,
|
|
197
|
-
};
|
|
355
|
+
// D-0098: the recipe itself is the choice of no browser screens; who made it is recorded.
|
|
356
|
+
if (recipe.screens === false)
|
|
357
|
+
answers[SCREENS] = fromFlag(SCREENS, NO_SCREENS);
|
|
358
|
+
return { answers, missing };
|
|
198
359
|
}
|
|
199
360
|
function option(options, path) {
|
|
200
361
|
let current = options;
|
|
@@ -205,42 +366,38 @@ function option(options, path) {
|
|
|
205
366
|
}
|
|
206
367
|
return current;
|
|
207
368
|
}
|
|
369
|
+
/** The CSRF next step of both views ends with this rule (one text, D-0110 §5). */
|
|
370
|
+
const CSRF_NEVER_OFF = "Never turn the check off to make a test pass; a webhook route that verifies its own signature, an @dokebi/auth Bearer-only route (auth: { via: 'token' }) or the token refresh route (it calls tokens.refresh) takes config: { csrf: false }";
|
|
208
371
|
/**
|
|
209
|
-
*
|
|
210
|
-
*
|
|
372
|
+
* The tellUser line of `--admin login`: where to sign in and where the first password is.
|
|
373
|
+
* `appUrl` is the app's `<scheme>://<domain>:<port>`, or APP_URL_PLACEHOLDER in the recipe record
|
|
374
|
+
* (verify fills in the address of the moment, AI-M05). `firstAdmin` is firstAdminPhrase, or
|
|
375
|
+
* FIRST_ADMIN_PLACEHOLDER in the record: verify reads the login and the section from the project
|
|
376
|
+
* as it is then (D-0115: the builder may rename the section or sign the admin in by email).
|
|
211
377
|
*/
|
|
212
|
-
export
|
|
213
|
-
const
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
debug('recipe: running', { command, args, root });
|
|
218
|
-
await new Promise((done, fail) => {
|
|
219
|
-
const child = spawn(file, argv, { cwd: root, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
220
|
-
const output = new OutputTail();
|
|
221
|
-
const keep = (chunk) => output.push(chunk);
|
|
222
|
-
child.stdout.on('data', keep);
|
|
223
|
-
child.stderr.on('data', keep);
|
|
224
|
-
const timer = setTimeout(() => child.kill('SIGKILL'), timeoutMs);
|
|
225
|
-
child.once('error', (cause) => {
|
|
226
|
-
clearTimeout(timer);
|
|
227
|
-
fail(new CliError('STEP_COMMAND_FAILED', `${command} could not run`, { cause }));
|
|
228
|
-
});
|
|
229
|
-
child.once('close', (code, signal) => {
|
|
230
|
-
clearTimeout(timer);
|
|
231
|
-
if (code === 0)
|
|
232
|
-
done();
|
|
233
|
-
else
|
|
234
|
-
fail(new CliError('STEP_COMMAND_FAILED', `${command} ${args.join(' ')} failed`, {
|
|
235
|
-
details: { exitCode: code, signal, output: output.text() },
|
|
236
|
-
}));
|
|
237
|
-
});
|
|
238
|
-
});
|
|
378
|
+
export function loginNotice(appUrl, devInfra, firstAdmin = FIRST_ADMIN_PLACEHOLDER) {
|
|
379
|
+
const ready = devInfra === 'docker'
|
|
380
|
+
? ''
|
|
381
|
+
: ' The account exists after npx dokebi-migrate up and npx dokebi-migrate seed (run them once the database URL is in dokebi.yaml).';
|
|
382
|
+
return `Admin login is on (@dokebi/auth): sign in at ${appUrl}/login ${firstAdmin}; ${LOGIN_PAGES}${ready}`;
|
|
239
383
|
}
|
|
240
|
-
/**
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
384
|
+
/**
|
|
385
|
+
* The hosts of a blog/api plan (D-0102): the recipe's `web.domain` is `auto`, the project's own
|
|
386
|
+
* development hosts `main.<label>.localhost` and `admin.<label>.localhost`, so two projects on one
|
|
387
|
+
* machine never share cookies. A recipe naming a domain keeps it (admin then on `admin.<domain>`).
|
|
388
|
+
* An app that already exists keeps the first domain dokebi.yaml maps to it (`options`), so a plan
|
|
389
|
+
* applied again to an older project (main on `localhost`) seals and links the host it really has.
|
|
390
|
+
*/
|
|
391
|
+
export function recipeHosts(recipe, label, options = {}) {
|
|
392
|
+
const domains = option(options, ['dokebi-app', 'domains']);
|
|
393
|
+
const mapped = (app) => domains && typeof domains === 'object'
|
|
394
|
+
? Object.entries(domains).find(([, target]) => target?.app === app)?.[0]
|
|
395
|
+
: undefined;
|
|
396
|
+
const domain = recipe.web.domain;
|
|
397
|
+
const defaults = domain === AUTO_DOMAIN
|
|
398
|
+
? { main: devHost('main', label), admin: devHost('admin', label) }
|
|
399
|
+
: { main: domain, admin: `admin.${domain}` };
|
|
400
|
+
return { main: mapped('main') ?? defaults.main, admin: mapped('admin') ?? defaults.admin };
|
|
244
401
|
}
|
|
245
402
|
/** Builds the plan steps for the answers (capability → package through the catalog). */
|
|
246
403
|
function steps(catalog, recipe, answers, context) {
|
|
@@ -252,13 +409,13 @@ function steps(catalog, recipe, answers, context) {
|
|
|
252
409
|
/** Installed by package name: the app and module hosts come from the catalog (D-0070). */
|
|
253
410
|
const hasPackage = async (name) => (await now())?.packages.has(name) ?? false;
|
|
254
411
|
const plugin = (capability) => provider(catalog, capability);
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
412
|
+
// Without an answer (a recipe that neither asks nor fixes the view) the catalog default holds.
|
|
413
|
+
const viewQuestion = catalog.choices.find((item) => item.id === 'view');
|
|
414
|
+
const viewChoice = viewQuestion?.options.find((item) => item.value === (value('view') ?? viewQuestion.default));
|
|
258
415
|
const inertia = viewChoice?.capability?.startsWith('view.inertia') ?? false;
|
|
259
416
|
const ssr = viewChoice?.capability === 'view.inertia-vue-ssr';
|
|
260
417
|
const appFlags = inertia ? ` --inertia${ssr ? ' --ssr' : ''}` : '';
|
|
261
|
-
const domain = recipe.
|
|
418
|
+
const { main: domain, admin: adminDomain } = recipeHosts(recipe, context.hostLabel);
|
|
262
419
|
if (context.init)
|
|
263
420
|
result.push({
|
|
264
421
|
id: 'init',
|
|
@@ -269,6 +426,16 @@ function steps(catalog, recipe, answers, context) {
|
|
|
269
426
|
await project.init(root);
|
|
270
427
|
},
|
|
271
428
|
});
|
|
429
|
+
const logger = plugin('logging.targets');
|
|
430
|
+
result.push({
|
|
431
|
+
id: 'logger-plugin',
|
|
432
|
+
command: `npx dokebi add ${logger}`,
|
|
433
|
+
why: 'the app logger (D-0151): JSON lines on stdout, readable lines in dokebi dev, redaction, and a rotating file target to turn on in production; init adds it, this step adds it to an older project',
|
|
434
|
+
satisfied: () => hasPackage(logger),
|
|
435
|
+
run: async () => {
|
|
436
|
+
await project.add(root, logger);
|
|
437
|
+
},
|
|
438
|
+
});
|
|
272
439
|
const apps = plugin('web.apps');
|
|
273
440
|
result.push({
|
|
274
441
|
id: 'app-plugin',
|
|
@@ -292,13 +459,48 @@ function steps(catalog, recipe, answers, context) {
|
|
|
292
459
|
]);
|
|
293
460
|
},
|
|
294
461
|
});
|
|
295
|
-
//
|
|
296
|
-
//
|
|
462
|
+
// The consumer convention seals every browser app (D-0092 §1, D-0062): a recipe with write
|
|
463
|
+
// forms is a browser app, so @dokebi/seal is installed and its hosts get seal: true. EJS: the
|
|
464
|
+
// shared layout (template-ejs) writes the seal head and @dokebi/seal/forms seals its forms.
|
|
465
|
+
// Inertia: the generated views/app.js awaits sealInertia before createInertiaApp, which opens
|
|
466
|
+
// the sealed first page and seals every visit. Server rendering cannot be sealed (SSR writes
|
|
467
|
+
// the props into plain HTML: SEAL_SSR_CONFLICT), so an SSR plan stays plain and its notice
|
|
468
|
+
// tells the user. The api recipe answers API clients (Bearer, server to server) and stays plain.
|
|
469
|
+
const sealed = recipe.web.forms === true && !ssr;
|
|
470
|
+
const sealEjs = sealed && !inertia;
|
|
471
|
+
if (sealed) {
|
|
472
|
+
const seal = plugin('security.seal');
|
|
473
|
+
result.push({
|
|
474
|
+
id: 'seal-plugin',
|
|
475
|
+
command: `npx dokebi add ${seal}`,
|
|
476
|
+
why: inertia
|
|
477
|
+
? `browser apps are sealed (the consumer convention): ${seal} seals the first page, the Inertia visits and the JSON bodies of the host and serves ${SEAL_HTTP_MODULE}, which views/app.js imports (as @dokebi/seal/http, through dokebiInertia() of vite.config.ts) for sealInertia before createInertiaApp`
|
|
478
|
+
: `browser apps are sealed (the consumer convention): ${seal} seals the JSON bodies of the host and serves /dokebi/seal/forms.mjs, which the shared layout loads with ${SEAL_HEAD}`,
|
|
479
|
+
satisfied: () => has('dokebi-seal'),
|
|
480
|
+
run: async () => {
|
|
481
|
+
await project.add(root, seal);
|
|
482
|
+
},
|
|
483
|
+
});
|
|
484
|
+
}
|
|
485
|
+
if (sealEjs)
|
|
486
|
+
result.push({
|
|
487
|
+
id: 'seal-domain',
|
|
488
|
+
command: `npx dokebi config set dokebi-app.domains.${domain}.seal=true`,
|
|
489
|
+
why: `seal the browser host ${domain}: page navigations stay plain HTML, every <form method="post"> goes sealed through @dokebi/seal/forms and a plain form post is refused (415 SEAL_REQUIRED); never turn this off to make a form work`,
|
|
490
|
+
satisfied: async () => option((await now())?.options ?? {}, ['dokebi-app', 'domains', domain, 'seal']) === true,
|
|
491
|
+
run: async () => {
|
|
492
|
+
await project.configSet(root, [
|
|
493
|
+
{ plugin: 'dokebi-app', set: { path: ['domains', domain, 'seal'], value: true } },
|
|
494
|
+
]);
|
|
495
|
+
},
|
|
496
|
+
});
|
|
497
|
+
// HTML forms (the blog's write form) are form bodies, which dokebi-web refuses (415
|
|
498
|
+
// FORM_BODY_DISABLED) until formBody is on (AI-M02), sealed or not. Inertia posts JSON.
|
|
297
499
|
if (recipe.web.forms && !inertia)
|
|
298
500
|
result.push({
|
|
299
501
|
id: 'form-body',
|
|
300
502
|
command: 'npx dokebi config set dokebi-web.formBody=true',
|
|
301
|
-
why: `HTML forms
|
|
503
|
+
why: `HTML forms are form bodies, sealed by @dokebi/seal/forms on a sealed host or not; dokebi-web refuses them (415 FORM_BODY_DISABLED) until formBody is on. ${csrfNotice(false)} The layout writes ${SEAL_HEAD} in its <head>: @dokebi/seal/forms then seals every post form (no per-form code), and the handler gets the same body.`,
|
|
302
504
|
satisfied: async () => option((await now())?.options ?? {}, ['dokebi-web', 'formBody']) === true,
|
|
303
505
|
run: async () => {
|
|
304
506
|
await project.configSet(root, [
|
|
@@ -319,11 +521,11 @@ function steps(catalog, recipe, answers, context) {
|
|
|
319
521
|
});
|
|
320
522
|
result.push({
|
|
321
523
|
id: 'main-app',
|
|
322
|
-
command: `npx dokebi app add main${appFlags} --domain ${domain}`,
|
|
323
|
-
why: `the app main on ${domain} with Vue pages (adds vite.config.ts, the build script, Vite and Vue)`,
|
|
524
|
+
command: `npx dokebi app add main${appFlags} --domain ${domain}${sealed ? ' --seal' : ''}`,
|
|
525
|
+
why: `the app main on ${domain} with Vue pages (adds vite.config.ts, the build script, Vite and Vue)${sealed ? '; --seal seals its host: the first page and every visit go sealed and a plain JSON body is refused (415 SEAL_REQUIRED); never turn this off to make a request work' : ''}`,
|
|
324
526
|
satisfied: async () => (await now())?.apps.includes('main') ?? false,
|
|
325
527
|
run: async () => {
|
|
326
|
-
await project.appAdd(root, 'main', { domains: [domain], inertia: true, ssr });
|
|
528
|
+
await project.appAdd(root, 'main', { domains: [domain], inertia: true, ssr, seal: sealed });
|
|
327
529
|
},
|
|
328
530
|
});
|
|
329
531
|
}
|
|
@@ -407,7 +609,7 @@ function steps(catalog, recipe, answers, context) {
|
|
|
407
609
|
result.push({
|
|
408
610
|
id: 'dev-infra',
|
|
409
611
|
command: `npx dokebi dev-infra${context.dbPort ? ` --db-port ${context.dbPort}` : ''}`,
|
|
410
|
-
why: 'compose.yaml
|
|
612
|
+
why: 'compose.yaml and a generated database URL/password only in dokebi.yaml',
|
|
411
613
|
satisfied: async () => {
|
|
412
614
|
const url = option((await now())?.options ?? {}, [
|
|
413
615
|
'dokebi-db',
|
|
@@ -426,34 +628,143 @@ function steps(catalog, recipe, answers, context) {
|
|
|
426
628
|
});
|
|
427
629
|
result.push({
|
|
428
630
|
id: 'dev-db-up',
|
|
429
|
-
command: '
|
|
631
|
+
command: 'npx dokebi dev-infra up --json',
|
|
430
632
|
why: `start the ${variant.name} container and wait until it is healthy`,
|
|
431
633
|
satisfied: () => composeServiceRunning(root),
|
|
432
|
-
run: () =>
|
|
634
|
+
run: async () => {
|
|
635
|
+
await runDevCompose(root, 'up');
|
|
636
|
+
},
|
|
433
637
|
});
|
|
434
638
|
}
|
|
435
639
|
}
|
|
436
|
-
|
|
640
|
+
result.push(...mailSteps({ root, project, choice: value('mail'), now }));
|
|
641
|
+
const admin = value('admin');
|
|
642
|
+
if (admin === 'app-code' || admin === 'login') {
|
|
437
643
|
const session = plugin('web.session');
|
|
438
644
|
result.push({
|
|
439
645
|
id: 'session',
|
|
440
646
|
command: `npx dokebi add ${session}`,
|
|
441
|
-
why:
|
|
647
|
+
why: `server-side sessions for the admin login${admin === 'login' ? ' (@dokebi/auth keeps it there)' : ' the app writes'}; add also writes the generated cookie-signing secret into dokebi.yaml (dokebi-session.secrets; 0600, kept out of Git; the value is never printed)`,
|
|
442
648
|
satisfied: () => has('dokebi-session'),
|
|
443
649
|
run: async () => {
|
|
444
650
|
await project.add(root, session);
|
|
445
651
|
},
|
|
446
652
|
});
|
|
653
|
+
}
|
|
654
|
+
// D-0076 §6: every write form gets CSRF protection, without a question (a security default).
|
|
655
|
+
if (recipe.web.forms) {
|
|
656
|
+
const csrf = plugin('csrf');
|
|
657
|
+
result.push({
|
|
658
|
+
id: 'csrf',
|
|
659
|
+
command: `npx dokebi add ${csrf}`,
|
|
660
|
+
why: `CSRF protection for the write forms (an origin check and a signed token on every POST, PUT, PATCH and DELETE): ${inertia ? 'Inertia sends the token by itself' : 'every EJS form writes <%- data.csrf.field %>'}; add writes its generated secret into dokebi.yaml (dokebi-csrf.secrets, never printed)`,
|
|
661
|
+
satisfied: () => has('dokebi-csrf'),
|
|
662
|
+
run: async () => {
|
|
663
|
+
await project.add(root, csrf);
|
|
664
|
+
},
|
|
665
|
+
});
|
|
666
|
+
}
|
|
667
|
+
const dialect = variant?.name;
|
|
668
|
+
if (admin === 'login' && dialect) {
|
|
669
|
+
const auth = plugin('auth');
|
|
670
|
+
result.push({
|
|
671
|
+
id: 'auth',
|
|
672
|
+
command: `npx dokebi add ${auth} --set homePath=${ADMIN_PATH}`,
|
|
673
|
+
why: `session login on the app's own user table (@dokebi/auth): it hashes and checks passwords, throttles logins and protects routes with config.auth; a signed-in user on the login page goes to ${ADMIN_PATH}; add writes its generated secret into dokebi.yaml (dokebi-auth.secrets, never printed)`,
|
|
674
|
+
satisfied: () => has('dokebi-auth'),
|
|
675
|
+
run: async () => {
|
|
676
|
+
await project.add(root, auth, false, [{ path: ['homePath'], value: ADMIN_PATH }]);
|
|
677
|
+
},
|
|
678
|
+
});
|
|
679
|
+
const files = async () => {
|
|
680
|
+
let existing = [];
|
|
681
|
+
try {
|
|
682
|
+
existing = await readdir(join(root, MIGRATIONS_DIRECTORY));
|
|
683
|
+
}
|
|
684
|
+
catch (error) {
|
|
685
|
+
if (!isMissing(error))
|
|
686
|
+
throw error;
|
|
687
|
+
}
|
|
688
|
+
return loginFiles(dialect, inertia ? 'inertia' : 'ejs', migrationPath(existing, new Date(), context.loginNames), await has('dokebi-mail'), context.adminSection, context.loginNames);
|
|
689
|
+
};
|
|
690
|
+
result.push({
|
|
691
|
+
id: 'login-code',
|
|
692
|
+
command: `write ${EXTENSION_FILE}, src/shared/models/members.ts, src/shared/services/members.ts, ${MIGRATIONS_DIRECTORY}/<time>_${context.loginNames.migration}.ts, ${SEED_FILE}, src/apps/main/routes/auth.ts, src/apps/main/controllers/auth.ts and its two pages, loginRoutes(route) in ${ROUTES_FILE}, dokebi.yaml options.${context.adminSection} (login ${context.adminLogin}, a generated ${ADMIN_PASSWORD_KEY} unless one is there)`,
|
|
693
|
+
why: `the admin login as user code (no command writes it): the extension class maps the app's own table ${context.loginNames.table} (${dialect}), the migration creates it, the seed adds the first admin (${context.adminLogin}) with passwords.hash of the generated initial password (never printed), and the main app gets GET/POST /login, POST /logout and ${ADMIN_PATH} (config.auth)`,
|
|
694
|
+
satisfied: async () => {
|
|
695
|
+
// Not the password key: it may be removed once the admin exists (it is never regenerated).
|
|
696
|
+
try {
|
|
697
|
+
for (const file of await files())
|
|
698
|
+
if ((await readFile(join(root, file.path), 'utf8')) !== file.content)
|
|
699
|
+
return false;
|
|
700
|
+
return /\bloginRoutes\(/.test(await readFile(join(root, ROUTES_FILE), 'utf8'));
|
|
701
|
+
}
|
|
702
|
+
catch (error) {
|
|
703
|
+
if (isMissing(error))
|
|
704
|
+
return false;
|
|
705
|
+
throw error;
|
|
706
|
+
}
|
|
707
|
+
},
|
|
708
|
+
run: async () => {
|
|
709
|
+
const recovery = await has('dokebi-mail');
|
|
710
|
+
await project.writeRecipeFiles(root, {
|
|
711
|
+
files: await files(),
|
|
712
|
+
edits: [{ path: ROUTES_FILE, edit: withLoginRoutes }],
|
|
713
|
+
config: {
|
|
714
|
+
section: context.adminSection,
|
|
715
|
+
values: [{ key: 'login', value: ADMIN_LOGIN }, { key: ADMIN_PASSWORD_KEY }],
|
|
716
|
+
},
|
|
717
|
+
});
|
|
718
|
+
if (recovery) {
|
|
719
|
+
const current = (await now())?.options ?? {};
|
|
720
|
+
const sets = [
|
|
721
|
+
{ plugin: 'dokebi-web', set: { path: ['formBody'], value: true } },
|
|
722
|
+
];
|
|
723
|
+
if (option(current, ['dokebi-auth', 'passwordReset']) === undefined)
|
|
724
|
+
sets.push({
|
|
725
|
+
plugin: 'dokebi-auth',
|
|
726
|
+
set: {
|
|
727
|
+
path: ['passwordReset', 'url'],
|
|
728
|
+
value: `http://${domain}:${context.port}/password/reset`,
|
|
729
|
+
},
|
|
730
|
+
});
|
|
731
|
+
await project.configSet(root, sets);
|
|
732
|
+
}
|
|
733
|
+
},
|
|
734
|
+
});
|
|
735
|
+
if (value('devInfra') === 'docker') {
|
|
736
|
+
const migrateBin = join(root, 'node_modules/.bin/dokebi-migrate');
|
|
737
|
+
result.push({
|
|
738
|
+
id: 'migrate-up',
|
|
739
|
+
command: 'npx dokebi-migrate up',
|
|
740
|
+
why: `create ${context.loginNames.table} in the development database (nothing to do when it is there)`,
|
|
741
|
+
satisfied: async () => false,
|
|
742
|
+
run: () => shell(root, migrateBin, ['up'], 300_000),
|
|
743
|
+
});
|
|
744
|
+
result.push({
|
|
745
|
+
id: 'seed',
|
|
746
|
+
command: 'npx dokebi-migrate seed',
|
|
747
|
+
why: `add the first admin (${context.adminLogin}) unless it exists; the initial password is hashed with passwords.hash`,
|
|
748
|
+
satisfied: async () => false,
|
|
749
|
+
run: () => shell(root, migrateBin, ['seed'], 300_000),
|
|
750
|
+
});
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
if (admin === 'app-code')
|
|
447
754
|
result.push({
|
|
448
755
|
id: 'admin-app',
|
|
449
|
-
command: `npx dokebi app add admin${appFlags} --domain
|
|
450
|
-
why: `a separate admin app on
|
|
756
|
+
command: `npx dokebi app add admin${appFlags} --domain ${adminDomain}${sealed ? ' --seal' : ''}`,
|
|
757
|
+
why: `a separate admin app on ${adminDomain}${sealed ? ' (sealed like the main host)' : ''}; its login (with @dokebi/auth) and permission checks are app code`,
|
|
451
758
|
satisfied: async () => (await now())?.apps.includes('admin') ?? false,
|
|
452
759
|
run: async () => {
|
|
453
|
-
await project.appAdd(root, 'admin', {
|
|
760
|
+
await project.appAdd(root, 'admin', {
|
|
761
|
+
domains: [adminDomain],
|
|
762
|
+
inertia,
|
|
763
|
+
ssr,
|
|
764
|
+
seal: sealed,
|
|
765
|
+
});
|
|
454
766
|
},
|
|
455
767
|
});
|
|
456
|
-
}
|
|
457
768
|
if (inertia)
|
|
458
769
|
result.push({
|
|
459
770
|
id: 'build',
|
|
@@ -468,6 +779,62 @@ async function preflight(root, answers, catalog, plan, port, dbPort,
|
|
|
468
779
|
/** The user left the choices to the agent (--defaults / --answers-from defaults). */
|
|
469
780
|
delegated) {
|
|
470
781
|
const result = [];
|
|
782
|
+
const current = await view(root);
|
|
783
|
+
const mail = answers.mail?.value;
|
|
784
|
+
if (mail === 'mailpit') {
|
|
785
|
+
await checkMailCompose(root);
|
|
786
|
+
const docker = await dockerAvailable();
|
|
787
|
+
if (!docker.ok)
|
|
788
|
+
result.push({
|
|
789
|
+
code: 'DOCKER_MISSING',
|
|
790
|
+
severity: 'error',
|
|
791
|
+
message: 'Mailpit requires running Docker with compose',
|
|
792
|
+
fix: 'Start Docker or ask the user to choose another --mail option.',
|
|
793
|
+
});
|
|
794
|
+
if (!(await exists(join(root, 'compose.mail.yaml'))) &&
|
|
795
|
+
(option(current?.options ?? {}, ['dokebi-nats', 'connections', 'recipeMail']) !== undefined ||
|
|
796
|
+
option(current?.options ?? {}, ['dokebi-job', 'queues', 'recipeMail']) !== undefined))
|
|
797
|
+
result.push({
|
|
798
|
+
code: 'MAIL_CONFIGURATION_REQUIRED',
|
|
799
|
+
severity: 'error',
|
|
800
|
+
message: 'The recipeMail connection or queue name is already in use',
|
|
801
|
+
fix: 'Keep the existing infrastructure and configure mail manually.',
|
|
802
|
+
});
|
|
803
|
+
// An existing mail setup belongs to the developer, unless this recipe created it.
|
|
804
|
+
if (current?.installed.has('dokebi-mail') && !(await exists(join(root, 'compose.mail.yaml'))))
|
|
805
|
+
result.push({
|
|
806
|
+
code: 'MAIL_CONFIGURATION_REQUIRED',
|
|
807
|
+
severity: 'error',
|
|
808
|
+
message: 'Mail is already configured outside this recipe',
|
|
809
|
+
fix: 'Keep it with --mail smtp or --mail ses; do not replace an existing transport implicitly.',
|
|
810
|
+
});
|
|
811
|
+
}
|
|
812
|
+
if (mail === 'smtp' || mail === 'ses') {
|
|
813
|
+
if (!current?.installed.has('dokebi-mail') ||
|
|
814
|
+
option(current.options, ['dokebi-mail', 'transport', 'driver']) !== mail)
|
|
815
|
+
result.push({
|
|
816
|
+
code: 'MAIL_CONFIGURATION_REQUIRED',
|
|
817
|
+
severity: 'error',
|
|
818
|
+
message: `Configure the selected ${mail} mail service before applying`,
|
|
819
|
+
fix: 'Run npx dokebi add @dokebi/mail, read its AI.md, and edit transport and from in dokebi.yaml. Never put credentials on a command line.',
|
|
820
|
+
});
|
|
821
|
+
}
|
|
822
|
+
if (mail !== 'mailpit' &&
|
|
823
|
+
plan.some((step) => step.id === 'login-code') &&
|
|
824
|
+
current?.installed.has('dokebi-mail')) {
|
|
825
|
+
const queues = option(current.options, ['dokebi-job', 'queues']);
|
|
826
|
+
const selected = option(current.options, ['dokebi-mail', 'queue']);
|
|
827
|
+
const names = queues && typeof queues === 'object' ? Object.keys(queues) : [];
|
|
828
|
+
if (!current.installed.has('dokebi-job') ||
|
|
829
|
+
!names.length ||
|
|
830
|
+
(typeof selected === 'string' && !names.includes(selected)))
|
|
831
|
+
result.push({
|
|
832
|
+
code: 'MAIL_QUEUE_REQUIRED',
|
|
833
|
+
severity: 'error',
|
|
834
|
+
message: 'Login recovery with @dokebi/mail requires a configured @dokebi/job queue',
|
|
835
|
+
fix: 'Install and configure @dokebi/job with a NATS connection, provision its queue, and set dokebi-mail.queue to that queue before applying this recipe. See @dokebi/job AI.md.',
|
|
836
|
+
});
|
|
837
|
+
}
|
|
471
838
|
const devInfra = plan.find((step) => step.id === 'dev-infra');
|
|
472
839
|
if (devInfra) {
|
|
473
840
|
const docker = await dockerAvailable();
|
|
@@ -506,7 +873,7 @@ delegated) {
|
|
|
506
873
|
}
|
|
507
874
|
}
|
|
508
875
|
const running = (await view(root)) ? (await server.status(root)).running : false;
|
|
509
|
-
if (!running && (await portInUse(port))) {
|
|
876
|
+
if (!(answers.apps?.value === 'none') && !running && (await portInUse(port))) {
|
|
510
877
|
const alternative = await freePortAfter(port);
|
|
511
878
|
debug('recipe: web port in use', { port, alternative, delegated });
|
|
512
879
|
result.push({
|
|
@@ -522,7 +889,7 @@ delegated) {
|
|
|
522
889
|
result.push({
|
|
523
890
|
code: 'VITE_BUILD',
|
|
524
891
|
severity: 'info',
|
|
525
|
-
message: 'vite, vue, @inertiajs/vue3
|
|
892
|
+
message: 'vite, vue, @inertiajs/vue3, @vitejs/plugin-vue, vue-tsc and @typescript/typescript6 are installed, npm run build runs and npm run typecheck checks the .vue files',
|
|
526
893
|
});
|
|
527
894
|
if (plan.some((step) => step.id === 'db'))
|
|
528
895
|
result.push({
|
|
@@ -546,6 +913,40 @@ function portAsks(checks) {
|
|
|
546
913
|
next: [...new Set(ports.map((item) => item.next))].join(' '),
|
|
547
914
|
};
|
|
548
915
|
}
|
|
916
|
+
/** The first-admin seed an earlier recipe wrote, if any (D-0115: it may read the older section). */
|
|
917
|
+
async function readSeed(root) {
|
|
918
|
+
try {
|
|
919
|
+
return { file: SEED_FILE, text: await readFile(join(root, SEED_FILE), 'utf8') };
|
|
920
|
+
}
|
|
921
|
+
catch (error) {
|
|
922
|
+
if (isMissing(error))
|
|
923
|
+
return undefined;
|
|
924
|
+
throw error;
|
|
925
|
+
}
|
|
926
|
+
}
|
|
927
|
+
/** The login names of the project: its migrations and login files (recipeLoginNames). */
|
|
928
|
+
async function readLoginNames(root) {
|
|
929
|
+
let migrations = [];
|
|
930
|
+
try {
|
|
931
|
+
migrations = await readdir(join(root, MIGRATIONS_DIRECTORY));
|
|
932
|
+
}
|
|
933
|
+
catch (error) {
|
|
934
|
+
if (!isMissing(error))
|
|
935
|
+
throw error;
|
|
936
|
+
}
|
|
937
|
+
const sources = [];
|
|
938
|
+
for (const file of LOGIN_NAME_SOURCES)
|
|
939
|
+
try {
|
|
940
|
+
sources.push(await readFile(join(root, file), 'utf8'));
|
|
941
|
+
}
|
|
942
|
+
catch (error) {
|
|
943
|
+
if (!isMissing(error))
|
|
944
|
+
throw error;
|
|
945
|
+
}
|
|
946
|
+
const names = recipeLoginNames(migrations, sources);
|
|
947
|
+
debug('recipe: login names', { table: names.table, migration: names.migration });
|
|
948
|
+
return names;
|
|
949
|
+
}
|
|
549
950
|
async function composePort(root) {
|
|
550
951
|
try {
|
|
551
952
|
const document = parse(await readFile(join(root, COMPOSE_FILE), 'utf8'));
|
|
@@ -558,7 +959,18 @@ async function composePort(root) {
|
|
|
558
959
|
throw new CliError('COMPOSE_INVALID', `${COMPOSE_FILE} cannot be read`, { cause: error });
|
|
559
960
|
}
|
|
560
961
|
}
|
|
561
|
-
function nextSteps(catalog, recipe, answers, plan, port) {
|
|
962
|
+
function nextSteps(catalog, recipe, answers, plan, port, answersFrom, adminSection, loginNames, adminLogin) {
|
|
963
|
+
if (recipe.name === 'site')
|
|
964
|
+
return [
|
|
965
|
+
{
|
|
966
|
+
command: 'read SITE.md',
|
|
967
|
+
why: 'app domains, realm extension hooks and worker configuration before serving traffic',
|
|
968
|
+
},
|
|
969
|
+
{
|
|
970
|
+
command: 'npx dokebi verify --json',
|
|
971
|
+
why: 'verify the configured site and report its remaining app-code work',
|
|
972
|
+
},
|
|
973
|
+
];
|
|
562
974
|
const result = [];
|
|
563
975
|
// The app and module hosts by capability (D-0070), the rest by the step that installed them.
|
|
564
976
|
const ids = new Set([HOST_CAPABILITY.apps, HOST_CAPABILITY.modules].flatMap((capability) => providersOf(catalog, capability).find((item) => item.kind === 'plugin')?.id ?? []));
|
|
@@ -572,6 +984,12 @@ function nextSteps(catalog, recipe, answers, plan, port) {
|
|
|
572
984
|
ids.add('dokebi-migrate');
|
|
573
985
|
if (plan.some((step) => step.id === 'session'))
|
|
574
986
|
ids.add('dokebi-session');
|
|
987
|
+
if (plan.some((step) => step.id === 'csrf'))
|
|
988
|
+
ids.add('dokebi-csrf');
|
|
989
|
+
if (plan.some((step) => step.id === 'auth'))
|
|
990
|
+
ids.add('dokebi-auth');
|
|
991
|
+
if (plan.some((step) => step.id === 'seal-plugin'))
|
|
992
|
+
ids.add('dokebi-seal');
|
|
575
993
|
for (const id of ids)
|
|
576
994
|
result.push({
|
|
577
995
|
command: `npx dokebi plugins doc ${id} --json`,
|
|
@@ -582,18 +1000,39 @@ function nextSteps(catalog, recipe, answers, plan, port) {
|
|
|
582
1000
|
if (answers.devInfra?.value === 'url')
|
|
583
1001
|
result.push({
|
|
584
1002
|
command: 'edit dokebi.yaml: options.dokebi-db.connections.main.options.url',
|
|
585
|
-
why: 'url mode: write the existing database URL (with its password) here
|
|
1003
|
+
why: 'url mode: write the existing database URL (with its password) here with your file editor, npx dokebi config set or npx dokebi config secret <path> --stdin (or ask the user to)',
|
|
586
1004
|
});
|
|
587
|
-
|
|
1005
|
+
const login = plan.some((step) => step.id === 'login-code');
|
|
1006
|
+
if (login && !plan.some((step) => step.id === 'seed'))
|
|
1007
|
+
result.push({
|
|
1008
|
+
command: 'npx dokebi-migrate up && npx dokebi-migrate seed',
|
|
1009
|
+
why: `after the database URL is in dokebi.yaml: creates ${loginNames.table} and the first admin (${adminLogin}); until then nobody can sign in`,
|
|
1010
|
+
});
|
|
1011
|
+
if (login) {
|
|
588
1012
|
result.push({
|
|
589
|
-
command:
|
|
590
|
-
why:
|
|
1013
|
+
command: `edit: the write routes (GET /posts/new, POST /posts) — { config: { auth: 'required' } } (or { can: '${loginNames.ability}' }); a link to /login and a sign-out form where data.auth.user (Inertia: auth.user) is set`,
|
|
1014
|
+
why: `only signed-in users reach the write routes: a visitor without a login gets 303 to /login (JSON clients 401). Login, sign-out and ${ADMIN_PATH} are in src/apps/main/routes/auth.ts; the role rule is authorize in ${EXTENSION_FILE}`,
|
|
591
1015
|
});
|
|
592
|
-
if (plan.some((step) => step.id === 'form-body'))
|
|
593
1016
|
result.push({
|
|
594
|
-
command:
|
|
595
|
-
why: `
|
|
1017
|
+
command: `edit: a password change page for signed-in users (${ADMIN_PATH}/password) — check the current password with passwords.verify, store passwords.hash(new) and a new logout_key, then await request.auth.logoutOtherDevices()`,
|
|
1018
|
+
why: `the first admin's password is the generated options.${adminSection}.${ADMIN_PASSWORD_KEY} in dokebi.yaml: tell the user to change it before going live and then remove the key. Never compare or hash passwords yourself (use the plugin's passwords helpers; npx dokebi check: plain-password-compare)`,
|
|
596
1019
|
});
|
|
1020
|
+
}
|
|
1021
|
+
if (answers.admin?.value === 'app-code')
|
|
1022
|
+
result.push({
|
|
1023
|
+
command: "edit src/apps/admin: the login with @dokebi/auth (npx dokebi add @dokebi/auth, then the three hooks of src/infra/auth/extension.ts on your user table), the login page and routes with config: { auth: 'required' }",
|
|
1024
|
+
why: '@dokebi/auth checks passwords, throttles logins and keeps the session: never compare or hash passwords yourself (passwords.hash for new ones) and show one sentence for every failed login. CSRF protection is already on (@dokebi/csrf)',
|
|
1025
|
+
});
|
|
1026
|
+
if (plan.some((step) => step.id === 'csrf'))
|
|
1027
|
+
result.push(plan.some((step) => step.id === 'view-plugin')
|
|
1028
|
+
? {
|
|
1029
|
+
command: 'nothing to write for CSRF in Inertia forms: the XSRF-TOKEN cookie becomes the X-XSRF-TOKEN header by itself; a fetch call sends that header',
|
|
1030
|
+
why: `@dokebi/csrf refuses a POST, PUT, PATCH or DELETE without its token (403 CSRF_FAILED). ${CSRF_NEVER_OFF}`,
|
|
1031
|
+
}
|
|
1032
|
+
: {
|
|
1033
|
+
command: 'edit: every <form method="post"> (write form, sign-out) — <%- data.csrf.field %> inside the form; a fetch call sends the XSRF-TOKEN cookie as the X-XSRF-TOKEN header',
|
|
1034
|
+
why: `@dokebi/csrf refuses a POST without its token (403 CSRF_FAILED; an expired form comes back with data.errors._form). npx dokebi check warns: form-without-csrf. ${CSRF_NEVER_OFF}`,
|
|
1035
|
+
});
|
|
597
1036
|
// D-0074: how a write form refuses input and shows it, the same for the schema and the app.
|
|
598
1037
|
if (recipe.web.forms)
|
|
599
1038
|
result.push(plan.some((step) => step.id === 'view-plugin')
|
|
@@ -602,17 +1041,25 @@ function nextSteps(catalog, recipe, answers, plan, port) {
|
|
|
602
1041
|
why: 'a refused field comes back to the form (422 → 303 back with errors, @dokebi/session); only keeps the GET after the redirect to the props that changed (npx dokebi check warns: inertia-form-only)',
|
|
603
1042
|
}
|
|
604
1043
|
: {
|
|
605
|
-
command: 'edit: every write form — <form method="post"
|
|
1044
|
+
command: 'edit: every write form — <form method="post"> with <%- data.csrf.field %> first, each field value="<%= data.old.<field> ?? \'\' %>" and its message from data.errors.<field>; in the handler throw validationError({ <field>: \'<message>\' }) from @dokebi/web',
|
|
606
1045
|
why: plan.some((step) => step.id === 'session')
|
|
607
1046
|
? 'a refused route schema and the app check give the same 422 VALIDATION_FAILED (D-0074); with @dokebi/session (in this plan) the refused form comes back to its page (303) with data.errors and data.old; never send a hand-made 422'
|
|
608
1047
|
: 'a refused route schema and the app check give the same 422 VALIDATION_FAILED (D-0074); never send a hand-made 422. Without a session the refused form shows views/default/errors/422.ejs (else errors/error.ejs) with fields, errors and old',
|
|
609
1048
|
});
|
|
610
1049
|
// D-0074 addendum 1: an EJS write form comes back to its page only with a session.
|
|
611
1050
|
if (recipe.web.forms && !plan.some((step) => step.id === 'view-plugin' || step.id === 'session'))
|
|
612
|
-
result.push(
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
1051
|
+
result.push(
|
|
1052
|
+
// AI-M05 F3: the defaults are the plan. A plugin outside it is not the agent's choice, with
|
|
1053
|
+
// --defaults or without: the user hears about it (a tellUser notice) and decides.
|
|
1054
|
+
answersFrom === 'defaults'
|
|
1055
|
+
? {
|
|
1056
|
+
command: 'nothing to add: @dokebi/session is not part of the defaults',
|
|
1057
|
+
why: `do not add it now (the user left the recipe's choices to you, not extra plugins); verify's tellUser tells the user that a refused form shows the 422 page and that ${SESSION_OFFER}`,
|
|
1058
|
+
}
|
|
1059
|
+
: {
|
|
1060
|
+
command: 'npx dokebi add @dokebi/session',
|
|
1061
|
+
why: `so a refused HTML form goes back to its page with the typed values and the field messages (D-0074 addendum 1) instead of the 422 page; only if the user wants it: ask first (it writes a generated signing secret into dokebi.yaml and keeps sessions in memory until another store is configured). verify's tellUser offers it to the user too`,
|
|
1062
|
+
});
|
|
616
1063
|
const absent = catalog.absent.map((entry) => entry.capability).join(', ');
|
|
617
1064
|
result.push({
|
|
618
1065
|
command: 'npx dokebi plugins search <feature> --json',
|
|
@@ -620,41 +1067,92 @@ function nextSteps(catalog, recipe, answers, plan, port) {
|
|
|
620
1067
|
});
|
|
621
1068
|
result.push({
|
|
622
1069
|
command: 'npx dokebi verify --json',
|
|
623
|
-
why: `doctor, typecheck, build, start on port ${port}, HTTP checks, stop — say done only when ok is true`,
|
|
1070
|
+
why: `doctor, check, typecheck, build, the project's tests, start on port ${port}, HTTP checks, stop — say done only when ok is true`,
|
|
624
1071
|
});
|
|
625
1072
|
if (answers.devInfra?.value === 'docker')
|
|
626
1073
|
result.push({
|
|
627
|
-
command: '
|
|
628
|
-
why: 'stop the development database when you are done (
|
|
1074
|
+
command: 'npx dokebi dev-infra down --json',
|
|
1075
|
+
why: 'stop the development database when you are done (npx dokebi dev-infra up starts it again)',
|
|
629
1076
|
});
|
|
630
1077
|
return result;
|
|
631
1078
|
}
|
|
632
1079
|
/** The lines to tell the user (RecipeOutcome.report). */
|
|
633
|
-
function report(
|
|
1080
|
+
function report(answers, answersFrom, plan, context) {
|
|
634
1081
|
const lines = [];
|
|
635
|
-
|
|
1082
|
+
// D-0098: view = none of a recipe without screens has its own line (below).
|
|
1083
|
+
const list = (by) => Object.entries(answers).filter(([id, item]) => !(context.noScreens && id === SCREENS) && item.answeredBy === by);
|
|
636
1084
|
const defaults = list('default');
|
|
637
1085
|
if (defaults.length)
|
|
638
1086
|
lines.push(`Chosen by default (tell the user): ${defaults
|
|
639
1087
|
.map(([id, item]) => `${id}=${item.value} (${item.defaultReason})`)
|
|
640
1088
|
.join('; ')}`);
|
|
641
|
-
const given = list('
|
|
1089
|
+
const given = list('user')
|
|
642
1090
|
.map(([id, item]) => `${id}=${item.value}`)
|
|
643
1091
|
.join(', ');
|
|
644
1092
|
if (given)
|
|
645
1093
|
lines.push(answersFrom === 'user'
|
|
646
1094
|
? `Answers from the user: ${given}`
|
|
647
|
-
:
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
1095
|
+
: `Answers not yet confirmed (${given}): apply needs --answers-from user (only choices the user or the request explicitly made; name yours in --ai-chose) or --defaults (only when the user said to decide)`);
|
|
1096
|
+
// D-0095: the agent's own choices, each with the reason it gave.
|
|
1097
|
+
const chosen = list('ai')
|
|
1098
|
+
.map(([id, item]) => `${id}=${item.value}${item.aiReason ? ` (${item.aiReason})` : ''}`)
|
|
1099
|
+
.join('; ');
|
|
1100
|
+
if (chosen)
|
|
1101
|
+
lines.push(`Chosen by you while the user left the choice to you (tell the user): ${chosen}`);
|
|
1102
|
+
const screens = context.noScreens ? answers[SCREENS] : undefined;
|
|
1103
|
+
if (screens)
|
|
1104
|
+
lines.push(`${SCREENS}=${NO_SCREENS} (${context.recipe} recipe, no browser screens): ${screens.answeredBy === 'ai'
|
|
1105
|
+
? `chosen by you while the user left the choice to you (tell the user in the first lines of your final message)${screens.aiReason ? `: ${screens.aiReason}` : ''}`
|
|
1106
|
+
: answersFrom === 'user'
|
|
1107
|
+
? "the user's choice (the request asks for an API that other programs call)"
|
|
1108
|
+
: `not yet confirmed: apply with --answers-from user only when the user or the request asked for an API without screens; otherwise it is your choice (--ai-chose ${SCREENS}) or a browser app (another recipe)`}`);
|
|
1109
|
+
// The record keeps the placeholders; the report names the address and the first admin the
|
|
1110
|
+
// plan uses (verify reads them from the project again).
|
|
1111
|
+
// D-0137: a fact notice says what the plan does (verify says what it finds in the project).
|
|
1112
|
+
lines.push(...notices(plan, context).map((line) => {
|
|
1113
|
+
const fact = factOf(line);
|
|
1114
|
+
if (fact)
|
|
1115
|
+
return plannedText(fact, context.planFacts);
|
|
1116
|
+
return line
|
|
1117
|
+
.replaceAll(APP_URL_PLACEHOLDER, `http://${context.domain}:${context.port}`)
|
|
1118
|
+
.replaceAll(FIRST_ADMIN_PLACEHOLDER, context.firstAdmin);
|
|
1119
|
+
}));
|
|
651
1120
|
return lines.join('\n');
|
|
652
1121
|
}
|
|
653
|
-
/**
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
1122
|
+
/**
|
|
1123
|
+
* What the user must know about the applied plan (in the report and `.dokebi/recipe.json`). A
|
|
1124
|
+
* notice that states a fact of the project is a `{fact:…}` token (D-0137): the report says it as
|
|
1125
|
+
* the plan makes it, verify as it finds it in the project.
|
|
1126
|
+
*/
|
|
1127
|
+
function notices(plan, context) {
|
|
1128
|
+
const lines = [];
|
|
1129
|
+
const has = (id) => plan.some((step) => step.id === id);
|
|
1130
|
+
const fact = (value) => lines.push(factToken(value));
|
|
1131
|
+
if (context.noScreens)
|
|
1132
|
+
lines.push(noScreensRecord(context.recipe, context.browserRecipes));
|
|
1133
|
+
if (has('site-files'))
|
|
1134
|
+
fact({ id: 'site' });
|
|
1135
|
+
if (has('csrf')) {
|
|
1136
|
+
if (has('site-files')) {
|
|
1137
|
+
fact({ id: 'csrf', mode: 'site-api' });
|
|
1138
|
+
if (has('app-web') || has('app-admin'))
|
|
1139
|
+
fact({ id: 'csrf', mode: has('view-plugin') ? 'site-inertia' : 'site-ejs' });
|
|
1140
|
+
}
|
|
1141
|
+
else
|
|
1142
|
+
fact({ id: 'csrf', mode: has('form-body') ? 'form-body' : 'inertia' });
|
|
1143
|
+
}
|
|
1144
|
+
if (context.sealed.length)
|
|
1145
|
+
fact({ id: 'seal', view: context.sealedView, domains: context.sealed });
|
|
1146
|
+
if (context.unsealedSsr.length)
|
|
1147
|
+
fact({ id: 'ssr-seal', domains: context.unsealedSsr });
|
|
1148
|
+
if (has('login-code'))
|
|
1149
|
+
lines.push(loginNotice(APP_URL_PLACEHOLDER, context.devInfra));
|
|
1150
|
+
if (has('mail-infra'))
|
|
1151
|
+
fact({ id: 'mail' });
|
|
1152
|
+
// AI-M05 F3: an EJS blog without a session; not installed behind the user's back.
|
|
1153
|
+
if (context.forms && !has('view-plugin') && !has('session'))
|
|
1154
|
+
fact({ id: 'session' });
|
|
1155
|
+
return lines;
|
|
658
1156
|
}
|
|
659
1157
|
/** A dry run's first next step: the same command applied, with the answer source to state. */
|
|
660
1158
|
function applyStep(recipe, request, answersFrom) {
|
|
@@ -663,6 +1161,11 @@ function applyStep(recipe, request, answersFrom) {
|
|
|
663
1161
|
db: '--db',
|
|
664
1162
|
devInfra: '--dev-infra',
|
|
665
1163
|
admin: '--admin',
|
|
1164
|
+
mail: '--mail',
|
|
1165
|
+
apps: '--apps',
|
|
1166
|
+
domains: '--domains',
|
|
1167
|
+
realms: '--realms',
|
|
1168
|
+
worker: '--worker',
|
|
666
1169
|
};
|
|
667
1170
|
const parts = [`npx dokebi recipe ${recipe.name}`];
|
|
668
1171
|
if (request.dir !== undefined)
|
|
@@ -681,6 +1184,11 @@ function applyStep(recipe, request, answersFrom) {
|
|
|
681
1184
|
if (request.defaults)
|
|
682
1185
|
parts.push('--defaults');
|
|
683
1186
|
}
|
|
1187
|
+
// D-0095: the answers the agent chose, and its reasons, stay what they were in the dry run.
|
|
1188
|
+
if (request.aiChose?.length)
|
|
1189
|
+
parts.push(`--ai-chose ${request.aiChose.join(',')}`);
|
|
1190
|
+
for (const [id, reason] of Object.entries(request.aiReasons ?? {}))
|
|
1191
|
+
parts.push(`--ai-reason ${JSON.stringify(`${id}=${reason}`)}`);
|
|
684
1192
|
parts.push('--json');
|
|
685
1193
|
return {
|
|
686
1194
|
command: parts.join(' '),
|
|
@@ -699,25 +1207,27 @@ export async function runRecipe(request, project) {
|
|
|
699
1207
|
.filter((id) => !recipe.fixed?.[id])
|
|
700
1208
|
.map((id) => `${choiceOf(catalog, id).flag} <${id}>`)
|
|
701
1209
|
.join(' ');
|
|
1210
|
+
const notice = recipe.screens === false ? noScreensNotice(recipe.name, screenRecipes(catalog)) : undefined;
|
|
702
1211
|
return {
|
|
703
1212
|
status: 'needs-answers',
|
|
704
1213
|
recipe: recipe.name,
|
|
705
1214
|
summary: recipe.summary,
|
|
706
1215
|
questions: questions(catalog, recipe, answers),
|
|
707
1216
|
missing,
|
|
708
|
-
next: `${ASK_AND_WAIT} Show every question with its options, pros/cons and the default with its reason. After the user answers: npx dokebi recipe ${recipe.name} ${flags} --answers-from user --dry-run --json, then the same without --dry-run.
|
|
1217
|
+
next: `${ASK_AND_WAIT}${notice ? ` ${notice}` : ''} Show every question with its options, pros/cons and the default with its reason. After the user answers: npx dokebi recipe ${recipe.name} ${flags} --answers-from user --dry-run --json, then the same without --dry-run. ${ANSWER_SOURCES} Tell the user the report.`,
|
|
1218
|
+
...(notice ? { notice } : {}),
|
|
709
1219
|
waitForUser: true,
|
|
710
1220
|
};
|
|
711
1221
|
}
|
|
712
|
-
const answersFrom =
|
|
1222
|
+
const answersFrom = answerSource(request);
|
|
713
1223
|
// D-0065 §11 (AI-M02): an agent printed the questions and installed anyway. Applying needs the
|
|
714
1224
|
// agent to say where the answers came from; it is recorded in the result (answersFrom, report).
|
|
715
1225
|
if (!request.dryRun && answersFrom === undefined)
|
|
716
|
-
throw new CliError('ANSWERS_SOURCE_REQUIRED', 'Say where the answers came from: --answers-from user (the user
|
|
1226
|
+
throw new CliError('ANSWERS_SOURCE_REQUIRED', 'Say where the answers came from: --answers-from user (the user or the request made these choices; name the ones you chose in --ai-chose) or --defaults (the user said to decide). If the user has not answered yet, ask and end your turn.', {
|
|
717
1227
|
phase: 'arguments',
|
|
718
1228
|
details: {
|
|
719
1229
|
answers: Object.fromEntries(Object.entries(answers).map(([id, item]) => [id, item.value])),
|
|
720
|
-
fix: `${ASK_AND_WAIT} Then run the same command with --answers-from user (or --defaults when the user left the choice to you)
|
|
1230
|
+
fix: `${ASK_AND_WAIT} Then run the same command with --answers-from user (or --defaults when the user left the choice to you). ${ANSWER_SOURCES}`,
|
|
721
1231
|
},
|
|
722
1232
|
});
|
|
723
1233
|
const root = resolve(request.dir ?? process.cwd());
|
|
@@ -731,14 +1241,72 @@ export async function runRecipe(request, project) {
|
|
|
731
1241
|
init = true;
|
|
732
1242
|
}
|
|
733
1243
|
const port = request.port ?? recipe.web.port;
|
|
734
|
-
|
|
1244
|
+
// D-0102: the default hosts carry the project's name (package.json, else the folder name).
|
|
1245
|
+
const hostLabel = await readProjectHostLabel(root);
|
|
1246
|
+
const hosts = recipeHosts(recipe, hostLabel, current?.options);
|
|
1247
|
+
const site = recipe.name === 'site' ? siteDefinition(answers, hostLabel) : undefined;
|
|
1248
|
+
if (site)
|
|
1249
|
+
await checkSite(root, answers, hostLabel);
|
|
1250
|
+
// D-0115: a project an older recipe gave options.blog-admin keeps that section.
|
|
1251
|
+
const seed = await readSeed(root);
|
|
1252
|
+
const adminSection = recipeAdminSection(current?.options ?? {}, seed?.text);
|
|
1253
|
+
const firstAdmin = describeFirstAdmin(current?.options ?? {}, seed);
|
|
1254
|
+
// D-0137: the login the plan names is the one it writes: an existing login key is kept.
|
|
1255
|
+
const adminLogin = firstAdmin.section === adminSection && firstAdmin.identity?.key === 'login'
|
|
1256
|
+
? firstAdmin.identity.value
|
|
1257
|
+
: ADMIN_LOGIN;
|
|
1258
|
+
// D-0115: a project an older recipe gave the blog_authors table keeps it (and its migration).
|
|
1259
|
+
const loginNames = await readLoginNames(root);
|
|
1260
|
+
const plan = (recipe.name === 'site' ? siteSteps : steps)(catalog, recipe, answers, {
|
|
735
1261
|
root,
|
|
736
1262
|
project,
|
|
737
1263
|
port,
|
|
1264
|
+
hostLabel,
|
|
738
1265
|
...(request.dbPort !== undefined ? { dbPort: request.dbPort } : {}),
|
|
739
1266
|
init,
|
|
740
1267
|
...(request.dir !== undefined ? { dir: request.dir } : {}),
|
|
1268
|
+
adminSection,
|
|
1269
|
+
adminLogin,
|
|
1270
|
+
loginNames,
|
|
741
1271
|
});
|
|
1272
|
+
const has = (id) => plan.some((step) => step.id === id);
|
|
1273
|
+
// The browser hosts of the plan: the site's page apps, or the blog's main (and admin) host.
|
|
1274
|
+
const browserHosts = site
|
|
1275
|
+
? site.apps.filter((app) => app !== 'api').map((app) => site.domains[app])
|
|
1276
|
+
: recipe.web.forms === true
|
|
1277
|
+
? [hosts.main, ...(has('admin-app') ? [hosts.admin] : [])]
|
|
1278
|
+
: [];
|
|
1279
|
+
const ssrView = answers.view?.value === 'inertia-vue-ssr';
|
|
1280
|
+
const noScreens = recipe.screens === false;
|
|
1281
|
+
const noticeContext = {
|
|
1282
|
+
recipe: recipe.name,
|
|
1283
|
+
browserRecipes: screenRecipes(catalog),
|
|
1284
|
+
noScreens,
|
|
1285
|
+
port,
|
|
1286
|
+
domain: site?.domains[site.apps[0] ?? ''] ?? hosts.main,
|
|
1287
|
+
devInfra: answers.devInfra?.value,
|
|
1288
|
+
// What the plan writes (an existing login key in that section is kept by the recipe).
|
|
1289
|
+
firstAdmin: firstAdminPhrase({
|
|
1290
|
+
section: adminSection,
|
|
1291
|
+
identity: { key: 'login', value: adminLogin, kind: 'login' },
|
|
1292
|
+
candidates: [],
|
|
1293
|
+
}),
|
|
1294
|
+
planFacts: {
|
|
1295
|
+
recipe: recipe.name,
|
|
1296
|
+
...(site ? { site } : {}),
|
|
1297
|
+
...(has('login-code') ? { adminLogin } : {}),
|
|
1298
|
+
},
|
|
1299
|
+
forms: recipe.web.forms === true,
|
|
1300
|
+
sealed: site
|
|
1301
|
+
? site.apps
|
|
1302
|
+
.filter((app) => sealedSiteApp(site, app))
|
|
1303
|
+
.map((app) => site.domains[app])
|
|
1304
|
+
: has('seal-plugin')
|
|
1305
|
+
? browserHosts
|
|
1306
|
+
: [],
|
|
1307
|
+
sealedView: has('view-plugin') ? 'inertia' : 'ejs',
|
|
1308
|
+
unsealedSsr: ssrView ? browserHosts : [],
|
|
1309
|
+
};
|
|
742
1310
|
const checks = await preflight(root, answers, catalog, plan, port, request.dbPort, answersFrom === 'defaults');
|
|
743
1311
|
const asks = portAsks(checks);
|
|
744
1312
|
const base = {
|
|
@@ -746,12 +1314,13 @@ export async function runRecipe(request, project) {
|
|
|
746
1314
|
root,
|
|
747
1315
|
answers,
|
|
748
1316
|
answersFrom: answersFrom ?? null,
|
|
749
|
-
report: report(
|
|
1317
|
+
report: report(answers, answersFrom, plan, noticeContext),
|
|
750
1318
|
plan: { steps: plan.map(({ id, command, why }) => ({ id, command, why })) },
|
|
1319
|
+
...(noScreens ? { notice: noScreensNotice(recipe.name, noticeContext.browserRecipes) } : {}),
|
|
751
1320
|
preflight: checks,
|
|
752
1321
|
nextSteps: [
|
|
753
1322
|
...(request.dryRun ? [applyStep(recipe, request, answersFrom)] : []),
|
|
754
|
-
...nextSteps(catalog, recipe, answers, plan, port),
|
|
1323
|
+
...nextSteps(catalog, recipe, answers, plan, port, answersFrom, adminSection, loginNames, adminLogin),
|
|
755
1324
|
],
|
|
756
1325
|
};
|
|
757
1326
|
const blocking = checks.filter((item) => item.severity === 'error');
|
|
@@ -794,8 +1363,11 @@ export async function runRecipe(request, project) {
|
|
|
794
1363
|
appliedAt: new Date().toISOString(),
|
|
795
1364
|
answersFrom: answersFrom,
|
|
796
1365
|
// In the recipe's question order, the order the user saw them in.
|
|
797
|
-
answers: Object.fromEntries(recipe.questions.flatMap((id) =>
|
|
798
|
-
notices: notices(
|
|
799
|
-
|
|
1366
|
+
answers: Object.fromEntries([...recipe.questions, ...(noScreens ? [SCREENS] : [])].flatMap((id) => answers[id] ? [[id, answers[id]]] : [])),
|
|
1367
|
+
notices: notices(plan, noticeContext),
|
|
1368
|
+
domain: site?.domains[site.apps[0] ?? ''] ?? hosts.main,
|
|
1369
|
+
},
|
|
1370
|
+
// D-0098: the recipe's answers replace the recorded views of the apps it made.
|
|
1371
|
+
site ? [...site.apps] : ['main', ...(has('admin-app') ? ['admin'] : [])]);
|
|
800
1372
|
return { status: 'applied', ...base, completedSteps, skippedSteps };
|
|
801
1373
|
}
|