@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-record.js
CHANGED
|
@@ -1,14 +1,52 @@
|
|
|
1
1
|
// What a recipe chose, kept for the end of the work (AI-M03): `dokebi recipe` writes
|
|
2
2
|
// `.dokebi/recipe.json` when it applied a recipe, and `dokebi verify` (the last command an agent
|
|
3
3
|
// runs) returns the lines the agent must pass on to the user as `tellUser`: every answer with who
|
|
4
|
-
// gave it (a default with its reason) and the notices, such as
|
|
4
|
+
// gave it (a default with its reason) and the notices, such as how forms send the CSRF token.
|
|
5
5
|
// The file holds choice values and catalog texts only, never secrets.
|
|
6
|
+
// A notice that names where the app answers holds APP_URL_PLACEHOLDER instead of an address
|
|
7
|
+
// (AI-M05: a recorded `http://localhost:3001/login` was relayed after the port became 3002);
|
|
8
|
+
// verify fills in the current address. Records written before keep their literal text, and a
|
|
9
|
+
// loopback address in it whose port is no longer the app's is replaced by the current one.
|
|
10
|
+
// Each answer says who settled it (D-0095): the user, the AI (the user left it to it), a default
|
|
11
|
+
// or the recipe. Records written before D-0095 say `flag`; reading turns it into `user` or `ai`.
|
|
12
|
+
// D-0098: the view of an app made outside a recipe (`dokebi app add`, the first app of the apps
|
|
13
|
+
// host) is recorded in `choices` of the same file, with who chose it; a recipe that covers the
|
|
14
|
+
// app replaces it. Files without `choices` read as none.
|
|
15
|
+
// D-0115: the login notice holds FIRST_ADMIN_PLACEHOLDER instead of the login name and the
|
|
16
|
+
// section of the initial password; verify fills in what the project configures then (the builder
|
|
17
|
+
// may rename the section or sign the admin in by email). A record from before says them in words;
|
|
18
|
+
// that part is replaced the same way, so a stale name is never relayed.
|
|
19
|
+
// D-0137: a notice that states a fact of the project (CSRF, Seal, form bodies, a session, local
|
|
20
|
+
// mail, the site's realms, user store and worker) is recorded as a `{fact:…}` token
|
|
21
|
+
// (recipe-facts.ts); verify says what it finds then. The old sentences of such notices are read
|
|
22
|
+
// the same way.
|
|
6
23
|
import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
|
|
7
24
|
import { dirname, join } from 'node:path';
|
|
25
|
+
import { isLoopbackHost } from './app-urls.js';
|
|
8
26
|
import { debug, isMissing, reportError } from './diagnostics.js';
|
|
27
|
+
import { firstAdminPhrase } from './first-admin.js';
|
|
28
|
+
import { factKey, factOf, unreadText } from './recipe-facts.js';
|
|
9
29
|
export const RECIPE_RECORD = '.dokebi/recipe.json';
|
|
10
|
-
/**
|
|
11
|
-
|
|
30
|
+
/**
|
|
31
|
+
* How the agent passes `tellUser` on (AI-M05 F6: an agent summarized the lines in another
|
|
32
|
+
* language instead of relaying them): verify's JSON carries it next to the lines, the human
|
|
33
|
+
* output prints it as the block's heading.
|
|
34
|
+
*/
|
|
35
|
+
export const TELL_USER_RELAY = {
|
|
36
|
+
tellUser: 'verbatim',
|
|
37
|
+
instruction: 'Copy these lines unchanged into your final message to the user: do not translate, reword or summarize them. You may add your own summary after them.',
|
|
38
|
+
};
|
|
39
|
+
/** Stands for `<scheme>://<domain>:<port>` of the app in a recorded notice. */
|
|
40
|
+
export const APP_URL_PLACEHOLDER = '{appUrl}';
|
|
41
|
+
/** Stands for the first admin's login and password key in the login notice (firstAdminPhrase). */
|
|
42
|
+
export const FIRST_ADMIN_PLACEHOLDER = '{firstAdmin}';
|
|
43
|
+
/** What the login notice says about protected pages (D-0115: no word of one kind of app). */
|
|
44
|
+
export const LOGIN_PAGES = 'pages that need a login send other visitors to the login page.';
|
|
45
|
+
/** The same part of the login notice as recipes before D-0115 recorded it, in words. */
|
|
46
|
+
const RECORDED_FIRST_ADMIN = / with the login name \S+ and the initial password stored in dokebi\.yaml at options\.[^\s.]+\.initialPassword \(it is never printed\); only signed-in users write posts\. Change that password before going live and then remove the key\./;
|
|
47
|
+
/** Both lists of the record; empty when there is no record (or it is unreadable). */
|
|
48
|
+
async function readRecord(root) {
|
|
49
|
+
const none = { recipes: [], choices: [] };
|
|
12
50
|
let text;
|
|
13
51
|
try {
|
|
14
52
|
text = await readFile(join(root, RECIPE_RECORD), 'utf8');
|
|
@@ -16,52 +54,218 @@ export async function readAppliedRecipes(root) {
|
|
|
16
54
|
catch (error) {
|
|
17
55
|
if (!isMissing(error))
|
|
18
56
|
reportError('RECIPE_RECORD_READ_FAILED', `Unable to read ${RECIPE_RECORD}`, error);
|
|
19
|
-
return
|
|
57
|
+
return none;
|
|
20
58
|
}
|
|
21
59
|
try {
|
|
22
60
|
const parsed = JSON.parse(text);
|
|
23
61
|
if (parsed.version === 1 && Array.isArray(parsed.recipes))
|
|
24
|
-
return
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
62
|
+
return {
|
|
63
|
+
recipes: parsed.recipes
|
|
64
|
+
.filter((entry) => typeof entry?.recipe === 'string' &&
|
|
65
|
+
entry.answers !== null &&
|
|
66
|
+
typeof entry.answers === 'object' &&
|
|
67
|
+
Array.isArray(entry.notices))
|
|
68
|
+
.map(migrateFlagAnswers),
|
|
69
|
+
choices: (Array.isArray(parsed.choices) ? parsed.choices : []).filter((entry) => typeof entry?.app === 'string' &&
|
|
70
|
+
entry.id === 'view' &&
|
|
71
|
+
typeof entry.value === 'string' &&
|
|
72
|
+
['user', 'ai', 'unstated'].includes(entry.answeredBy)),
|
|
73
|
+
};
|
|
28
74
|
debug('Recipe record ignored: unexpected content', { file: RECIPE_RECORD });
|
|
29
75
|
}
|
|
30
76
|
catch (error) {
|
|
31
77
|
reportError('RECIPE_RECORD_INVALID', `${RECIPE_RECORD} is not valid JSON; ignoring it`, error);
|
|
32
78
|
}
|
|
33
|
-
return
|
|
79
|
+
return none;
|
|
34
80
|
}
|
|
35
|
-
/**
|
|
36
|
-
export async function
|
|
81
|
+
/** The recipes recorded in the project; none when there is no record (or it is unreadable). */
|
|
82
|
+
export async function readAppliedRecipes(root) {
|
|
83
|
+
return (await readRecord(root)).recipes;
|
|
84
|
+
}
|
|
85
|
+
/** D-0098: the choices recorded outside recipes (the views of apps). */
|
|
86
|
+
export async function readRecordedChoices(root) {
|
|
87
|
+
return (await readRecord(root)).choices;
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* A record written before D-0095 says `answeredBy: 'flag'` for every given answer, and
|
|
91
|
+
* `answersFrom` said whose they were: `user` → the user's, `defaults` → the agent's. The same
|
|
92
|
+
* lines come out of tellUser as before.
|
|
93
|
+
*/
|
|
94
|
+
function migrateFlagAnswers(entry) {
|
|
95
|
+
const old = Object.entries(entry.answers).filter(([, answer]) => answer?.answeredBy === 'flag');
|
|
96
|
+
if (!old.length)
|
|
97
|
+
return entry;
|
|
98
|
+
const answeredBy = entry.answersFrom === 'user' ? 'user' : 'ai';
|
|
99
|
+
debug('Recipe record: old flag answers read as their source', {
|
|
100
|
+
recipe: entry.recipe,
|
|
101
|
+
answers: old.map(([id]) => id),
|
|
102
|
+
answeredBy,
|
|
103
|
+
});
|
|
104
|
+
return {
|
|
105
|
+
...entry,
|
|
106
|
+
answers: Object.fromEntries(Object.entries(entry.answers).map(([id, answer]) => [
|
|
107
|
+
id,
|
|
108
|
+
answer.answeredBy === 'flag' ? { ...answer, answeredBy } : answer,
|
|
109
|
+
])),
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
async function writeRecord(root, record) {
|
|
37
113
|
const file = join(root, RECIPE_RECORD);
|
|
38
|
-
const recipes = [
|
|
39
|
-
...(await readAppliedRecipes(root)).filter((item) => item.recipe !== entry.recipe),
|
|
40
|
-
entry,
|
|
41
|
-
];
|
|
42
|
-
const record = { version: 1, recipes };
|
|
43
114
|
await mkdir(dirname(file), { recursive: true });
|
|
44
115
|
await rm(`${file}.tmp`, { force: true });
|
|
45
116
|
await writeFile(`${file}.tmp`, `${JSON.stringify(record, null, 2)}\n`);
|
|
46
117
|
await rename(`${file}.tmp`, file);
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Records an applied recipe, replacing an earlier application of the same recipe. The recorded
|
|
121
|
+
* app choices of `apps` (the apps the recipe answers for) give way to the recipe's answers.
|
|
122
|
+
*/
|
|
123
|
+
export async function saveAppliedRecipe(root, entry, apps = []) {
|
|
124
|
+
const current = await readRecord(root);
|
|
125
|
+
const recipes = [...current.recipes.filter((item) => item.recipe !== entry.recipe), entry];
|
|
126
|
+
const choices = current.choices.filter((item) => !apps.includes(item.app));
|
|
127
|
+
if (choices.length < current.choices.length)
|
|
128
|
+
debug('Recipe record: app choices replaced by the recipe', { recipe: entry.recipe, apps });
|
|
129
|
+
await writeRecord(root, { version: 1, recipes, ...(choices.length ? { choices } : {}) });
|
|
47
130
|
debug('Recipe record written', { recipe: entry.recipe, file: RECIPE_RECORD });
|
|
48
131
|
}
|
|
132
|
+
/**
|
|
133
|
+
* `dokebi app remove`: drops the choices recorded for `app`. True when the record changed (the
|
|
134
|
+
* file is left alone when it holds none of that app).
|
|
135
|
+
*/
|
|
136
|
+
export async function forgetAppChoices(root, app) {
|
|
137
|
+
const current = await readRecord(root);
|
|
138
|
+
const choices = current.choices.filter((item) => item.app !== app);
|
|
139
|
+
if (choices.length === current.choices.length)
|
|
140
|
+
return false;
|
|
141
|
+
await writeRecord(root, {
|
|
142
|
+
version: 1,
|
|
143
|
+
recipes: current.recipes,
|
|
144
|
+
...(choices.length ? { choices } : {}),
|
|
145
|
+
});
|
|
146
|
+
debug('Recipe record: choices of a removed app dropped', { app, file: RECIPE_RECORD });
|
|
147
|
+
return true;
|
|
148
|
+
}
|
|
149
|
+
/** D-0098: records the view of an app made outside a recipe, replacing that app's earlier one. */
|
|
150
|
+
export async function saveRecordedChoice(root, choice) {
|
|
151
|
+
const current = await readRecord(root);
|
|
152
|
+
const choices = [
|
|
153
|
+
...current.choices.filter((item) => item.app !== choice.app || item.id !== choice.id),
|
|
154
|
+
choice,
|
|
155
|
+
];
|
|
156
|
+
await writeRecord(root, { version: 1, recipes: current.recipes, choices });
|
|
157
|
+
debug('Recipe record: app choice written', {
|
|
158
|
+
app: choice.app,
|
|
159
|
+
id: choice.id,
|
|
160
|
+
value: choice.value,
|
|
161
|
+
answeredBy: choice.answeredBy,
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
const ADDRESS = /\b(https?):\/\/([A-Za-z0-9.-]+|\[[0-9A-Fa-f:]+\]):(\d{1,5})(?![\d])/g;
|
|
165
|
+
/** `<scheme>://<host>:<port>` of a URL; undefined when it does not parse. */
|
|
166
|
+
function origin(url) {
|
|
167
|
+
try {
|
|
168
|
+
const parsed = new URL(url);
|
|
169
|
+
return { href: `${parsed.protocol}//${parsed.host}`, hostname: parsed.hostname };
|
|
170
|
+
}
|
|
171
|
+
catch (error) {
|
|
172
|
+
debug('Skipped an app address that is not a URL', { url, error: String(error) });
|
|
173
|
+
return undefined;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
/** The current address of the app for `domain`: a url on that host, else the first, else the port. */
|
|
177
|
+
function appOrigin(domain, address) {
|
|
178
|
+
const origins = address.urls.flatMap((url) => origin(url) ?? []);
|
|
179
|
+
const match = origins.find((item) => item.hostname === domain) ?? origins[0];
|
|
180
|
+
if (match)
|
|
181
|
+
return match.href;
|
|
182
|
+
return address.port ? `http://${domain}:${address.port}` : undefined;
|
|
183
|
+
}
|
|
184
|
+
/** Whether a recorded notice names the first admin (filled from the project by verify). */
|
|
185
|
+
export function namesFirstAdmin(notice) {
|
|
186
|
+
return notice.includes(FIRST_ADMIN_PLACEHOLDER) || RECORDED_FIRST_ADMIN.test(notice);
|
|
187
|
+
}
|
|
188
|
+
/** What the no-screens notice said before D-0137 (present tense: false once screens are added). */
|
|
189
|
+
const RECORDED_NO_SCREENS = 'the app answers JSON for other programs; people cannot sign up, browse or press buttons in a browser.';
|
|
190
|
+
/** The same part as the recipe says it now: what the recipe built, true whatever comes later. */
|
|
191
|
+
export const NO_SCREENS_BUILT = 'the recipe built a JSON API for other programs, with no pages where people sign up, browse or press buttons.';
|
|
192
|
+
/**
|
|
193
|
+
* One recorded notice as the user hears it now. A fact notice (D-0137) is the text verify read
|
|
194
|
+
* from the project (`facts`, by factKey), or, without it, that the CLI could not read it; '' when
|
|
195
|
+
* nothing is left to say. Any other notice gets the current app address: the placeholder filled
|
|
196
|
+
* in, or (a record from before the placeholder) a loopback address whose port is not the app's
|
|
197
|
+
* replaced by the current one; without a current address the placeholder becomes
|
|
198
|
+
* `http://<domain>`. The first admin placeholder gets `firstAdmin` (D-0115).
|
|
199
|
+
*/
|
|
200
|
+
export function fillNotice(notice, entry, address, firstAdmin = firstAdminPhrase({ candidates: [] }), facts) {
|
|
201
|
+
const fact = factOf(notice);
|
|
202
|
+
if (fact)
|
|
203
|
+
return facts?.get(factKey(entry.recipe, fact)) ?? unreadText(fact, entry.recipe);
|
|
204
|
+
if (notice.includes(RECORDED_NO_SCREENS)) {
|
|
205
|
+
debug('Recorded no-screens notice: said as what the recipe built', { recipe: entry.recipe });
|
|
206
|
+
notice = notice.replace(RECORDED_NO_SCREENS, NO_SCREENS_BUILT);
|
|
207
|
+
}
|
|
208
|
+
if (RECORDED_FIRST_ADMIN.test(notice)) {
|
|
209
|
+
debug('Recorded login notice: the first admin is read from the project', {
|
|
210
|
+
recipe: entry.recipe,
|
|
211
|
+
});
|
|
212
|
+
notice = notice.replace(RECORDED_FIRST_ADMIN, ` ${FIRST_ADMIN_PLACEHOLDER}; ${LOGIN_PAGES}`);
|
|
213
|
+
}
|
|
214
|
+
notice = notice.replaceAll(FIRST_ADMIN_PLACEHOLDER, firstAdmin);
|
|
215
|
+
const domain = entry.domain ?? 'localhost';
|
|
216
|
+
if (notice.includes(APP_URL_PLACEHOLDER))
|
|
217
|
+
return notice.replaceAll(APP_URL_PLACEHOLDER, appOrigin(domain, address) ?? `http://${domain}`);
|
|
218
|
+
return notice.replace(ADDRESS, (text, scheme, host, port) => {
|
|
219
|
+
if (!isLoopbackHost(host))
|
|
220
|
+
return text;
|
|
221
|
+
const current = appOrigin(host.replace(/^\[|\]$/g, ''), address);
|
|
222
|
+
if (!current || current === `${scheme}://${host}:${port}`)
|
|
223
|
+
return text;
|
|
224
|
+
debug('Recorded notice address replaced by the current one', {
|
|
225
|
+
recipe: entry.recipe,
|
|
226
|
+
recorded: `${scheme}://${host}:${port}`,
|
|
227
|
+
current,
|
|
228
|
+
});
|
|
229
|
+
return current;
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
/** What the user hears about an answer the agent chose (D-0095), with its reason when given. */
|
|
233
|
+
export const AI_CHOICE_LINE = 'chosen by the AI (you left the choice to it)';
|
|
234
|
+
/** D-0098: an EJS app added without saying who chose EJS. */
|
|
235
|
+
export const UNSTATED_VIEW_LINE = 'not the default view (Inertia + Vue), and who chose it was not recorded';
|
|
49
236
|
/**
|
|
50
237
|
* The lines for the user's final message: each answer and who chose it (a default with its
|
|
51
|
-
* reason
|
|
238
|
+
* reason, an AI choice with the agent's reason when it gave one), then the notices with the
|
|
239
|
+
* current app address. Answers fixed by the recipe itself are not choices and are left out.
|
|
52
240
|
*/
|
|
53
|
-
export function tellUserLines(recipes
|
|
241
|
+
export function tellUserLines(recipes, address = { urls: [] }, choices = [],
|
|
242
|
+
/** firstAdminPhrase of the project now (D-0115); without it, the not-found words. */
|
|
243
|
+
firstAdmin,
|
|
244
|
+
/** D-0137: the fact notices as verify read them from the project (factTexts). */
|
|
245
|
+
facts) {
|
|
54
246
|
const lines = [];
|
|
55
247
|
for (const entry of recipes) {
|
|
56
248
|
const prefix = recipes.length > 1 ? `[${entry.recipe}] ` : '';
|
|
57
249
|
for (const [id, answer] of Object.entries(entry.answers)) {
|
|
58
250
|
if (answer.answeredBy === 'default')
|
|
59
251
|
lines.push(`${prefix}${id} = ${answer.value}: chosen by default (${answer.defaultReason ?? 'the recipe default'})`);
|
|
60
|
-
else if (answer.answeredBy === '
|
|
61
|
-
lines.push(`${prefix}${id} = ${answer.value}:
|
|
252
|
+
else if (answer.answeredBy === 'user')
|
|
253
|
+
lines.push(`${prefix}${id} = ${answer.value}: your answer`);
|
|
254
|
+
else if (answer.answeredBy === 'ai')
|
|
255
|
+
lines.push(`${prefix}${id} = ${answer.value}: ${AI_CHOICE_LINE}${answer.aiReason ? `: ${answer.aiReason}` : ''}`);
|
|
256
|
+
}
|
|
257
|
+
for (const notice of entry.notices) {
|
|
258
|
+
const text = fillNotice(notice, entry, address, firstAdmin, facts);
|
|
259
|
+
if (text)
|
|
260
|
+
lines.push(`${prefix}${text}`);
|
|
62
261
|
}
|
|
63
|
-
for (const notice of entry.notices)
|
|
64
|
-
lines.push(`${prefix}${notice}`);
|
|
65
262
|
}
|
|
263
|
+
// D-0098: the views of apps made outside a recipe, each with who chose it.
|
|
264
|
+
for (const choice of choices)
|
|
265
|
+
lines.push(`[app ${choice.app}] ${choice.id} = ${choice.value}: ${choice.answeredBy === 'user'
|
|
266
|
+
? 'your answer'
|
|
267
|
+
: choice.answeredBy === 'ai'
|
|
268
|
+
? `${AI_CHOICE_LINE}${choice.aiReason ? `: ${choice.aiReason}` : ''}`
|
|
269
|
+
: UNSTATED_VIEW_LINE}`);
|
|
66
270
|
return lines;
|
|
67
271
|
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
export function resetFiles() {
|
|
2
|
+
return [
|
|
3
|
+
{ path: 'src/apps/main/routes/password-reset.ts', content: ROUTES },
|
|
4
|
+
{ path: 'src/apps/main/controllers/password-reset.ts', content: CONTROLLER },
|
|
5
|
+
{ path: 'src/apps/main/views/default/password-forgot.ejs', content: FORGOT },
|
|
6
|
+
{ path: 'src/apps/main/views/default/password-reset.ejs', content: RESET },
|
|
7
|
+
{ path: 'src/shared/mail/password-reset/en/subject.ejs', content: 'Reset your password\n' },
|
|
8
|
+
{
|
|
9
|
+
path: 'src/shared/mail/password-reset/en/html.ejs',
|
|
10
|
+
content: '<p><a href="<%= data.resetUrl %>">Reset your password</a></p><p>This link expires in <%= data.expiresInMinutes %> minutes. If you did not request it, ignore this email.</p>\n',
|
|
11
|
+
},
|
|
12
|
+
];
|
|
13
|
+
}
|
|
14
|
+
const ROUTES = `import type { AppRouter } from '@dokebi/app';
|
|
15
|
+
import { PasswordResetController } from '../controllers/password-reset.ts';
|
|
16
|
+
|
|
17
|
+
export function passwordResetRoutes(route: AppRouter): void {
|
|
18
|
+
const controller = new PasswordResetController();
|
|
19
|
+
route.get(
|
|
20
|
+
'/password/forgot',
|
|
21
|
+
(_request, reply, page) => controller.forgotPage(_request, reply, page),
|
|
22
|
+
{ config: { auth: false } },
|
|
23
|
+
);
|
|
24
|
+
route.post(
|
|
25
|
+
'/password/forgot',
|
|
26
|
+
(request, reply, page) => controller.requestReset(request, reply, page),
|
|
27
|
+
{ config: { auth: false } },
|
|
28
|
+
);
|
|
29
|
+
route.get(
|
|
30
|
+
'/password/reset',
|
|
31
|
+
(request, reply, page) => controller.resetPage(request, reply, page),
|
|
32
|
+
{ config: { auth: false } },
|
|
33
|
+
);
|
|
34
|
+
route.post('/password/reset', (request, reply, page) => controller.reset(request, reply, page), {
|
|
35
|
+
config: { auth: false },
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
`;
|
|
39
|
+
const CONTROLLER = `// Password recovery. Mail is queued; tokens never go into a session or a log.
|
|
40
|
+
import type { AppPage } from '@dokebi/app';
|
|
41
|
+
import type { FastifyRequest, FastifyReply } from 'fastify';
|
|
42
|
+
import { validationError } from '@dokebi/web';
|
|
43
|
+
|
|
44
|
+
export class PasswordResetController {
|
|
45
|
+
forgotPage(_request: FastifyRequest, reply: FastifyReply, page: AppPage) {
|
|
46
|
+
return page.view.reply(reply, 'password-forgot', { sent: false }, { layout: false });
|
|
47
|
+
}
|
|
48
|
+
async requestReset(request: FastifyRequest, reply: FastifyReply, page: AppPage) {
|
|
49
|
+
const body = (request.body ?? {}) as Record<string, unknown>;
|
|
50
|
+
const result = await request.auth.passwordReset.request(
|
|
51
|
+
typeof body.identity === 'string' ? body.identity : '',
|
|
52
|
+
);
|
|
53
|
+
if (!result.ok && result.error) throw result.error;
|
|
54
|
+
return page.view.reply(reply, 'password-forgot', { sent: true }, { layout: false });
|
|
55
|
+
}
|
|
56
|
+
async resetPage(request: FastifyRequest, reply: FastifyReply, page: AppPage) {
|
|
57
|
+
const token = (request.query as { token?: unknown }).token;
|
|
58
|
+
const text = typeof token === 'string' ? token : '';
|
|
59
|
+
const checked = await request.auth.passwordReset.check(text);
|
|
60
|
+
return page.view.reply(
|
|
61
|
+
reply,
|
|
62
|
+
'password-reset',
|
|
63
|
+
{
|
|
64
|
+
token: checked.ok ? text : '',
|
|
65
|
+
error: checked.ok ? '' : 'This link is invalid or expired. Request another link.',
|
|
66
|
+
},
|
|
67
|
+
{ layout: false },
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
async reset(request: FastifyRequest, reply: FastifyReply, page: AppPage) {
|
|
71
|
+
const body = (request.body ?? {}) as Record<string, unknown>;
|
|
72
|
+
const token = typeof body.token === 'string' ? body.token : '';
|
|
73
|
+
const password = typeof body.password === 'string' ? body.password : '';
|
|
74
|
+
// A no-referrer page cannot rely on the generic form-back redirect. Render its field error
|
|
75
|
+
// directly, with the same validationError message, keeping only the token (never password).
|
|
76
|
+
if (password.length < 12 || Buffer.byteLength(password) > 1024) {
|
|
77
|
+
const invalid = validationError({
|
|
78
|
+
password: 'Use at least 12 characters and at most 1024 bytes.',
|
|
79
|
+
});
|
|
80
|
+
return page.view.reply(
|
|
81
|
+
reply.code(422),
|
|
82
|
+
'password-reset',
|
|
83
|
+
{ token, error: invalid.errors.password },
|
|
84
|
+
{ layout: false },
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
const result = await request.auth.passwordReset.complete({ token, password });
|
|
88
|
+
if (!result.ok) {
|
|
89
|
+
if (result.error) throw result.error;
|
|
90
|
+
return page.view.reply(
|
|
91
|
+
reply.code(422),
|
|
92
|
+
'password-reset',
|
|
93
|
+
{ token: '', error: 'This link is invalid or expired. Request another link.' },
|
|
94
|
+
{ layout: false },
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
return reply.redirect('/login', 303);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
`;
|
|
101
|
+
// Both pages render without the shared layout ({ layout: false }), so they write the seal head
|
|
102
|
+
// themselves (D-0092 §1.5): on a sealed domain it loads @dokebi/seal/forms, which seals their post
|
|
103
|
+
// forms. `?.` keeps the pages working on an @dokebi/app that does not provide data.seal yet.
|
|
104
|
+
const FORGOT = `<!doctype html>
|
|
105
|
+
<html lang="en"><head><meta charset="utf-8"><meta name="referrer" content="no-referrer"><title>Reset your password</title><%- data.seal?.head ?? '' %></head><body>
|
|
106
|
+
<h1>Reset your password</h1>
|
|
107
|
+
<% if (data.sent) { %><p>If an eligible account exists, a reset link will be emailed to it.</p><% } %>
|
|
108
|
+
<form method="post" action="/password/forgot"><%- data.csrf.field %>
|
|
109
|
+
<label>Login <input name="identity" autocomplete="username" required></label><button>Send reset link</button></form>
|
|
110
|
+
<a href="/login">Back to login</a></body></html>\n`;
|
|
111
|
+
const RESET = `<!doctype html>
|
|
112
|
+
<html lang="en"><head><meta charset="utf-8"><meta name="referrer" content="no-referrer"><title>Choose a password</title><%- data.seal?.head ?? '' %></head><body>
|
|
113
|
+
<h1>Choose a new password</h1><% if (data.error) { %><p role="alert"><%= data.error %></p><% } %>
|
|
114
|
+
<% if (data.token) { %><form method="post" action="/password/reset"><%- data.csrf.field %>
|
|
115
|
+
<input type="hidden" name="token" value="<%= data.token %>">
|
|
116
|
+
<label>New password <input type="password" name="password" autocomplete="new-password" minlength="12" required></label><button>Change password</button></form><% } %>
|
|
117
|
+
<a href="/password/forgot">Request another link</a></body></html>\n`;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { Catalog, CatalogRecipe } from './catalog.js';
|
|
2
|
+
import type { Project } from './project.js';
|
|
3
|
+
import { type Answer, type Step } from './recipe-step.js';
|
|
4
|
+
/** Where older site recipes wrote the job worker; it keeps working (doctor LEGACY_LAYOUT). */
|
|
5
|
+
export declare const LEGACY_SITE_WORKER_PATH = "src/site-worker.ts";
|
|
6
|
+
/** Process-level runtime wiring sits in src/infra/runtime, beside the cluster worker factory. */
|
|
7
|
+
export declare const SITE_WORKER_PATH: string;
|
|
8
|
+
/**
|
|
9
|
+
* The opening of SITE.md, the guide the recipe writes once (kept byte for byte, so a re-run finds
|
|
10
|
+
* the guide it wrote). Its conditions ("until connected", "needs … before verify") are how the
|
|
11
|
+
* scaffold starts. D-0137: it is no longer a recorded notice: the record holds the `{fact:site}`
|
|
12
|
+
* token and verify says what it finds; records written before keep this text and are read the
|
|
13
|
+
* same way (recipe-facts.ts recordedFact).
|
|
14
|
+
*/
|
|
15
|
+
export declare const SITE_NOTICE = "Site scaffolding separates domains and auth realms. User lookup and login screens are app code: the generated auth extensions deny every login until connected to your user store. Admin and API routes require authentication. A job worker needs a configured NATS connection and a provisioned siteWorker queue before verify; the thread worker handles CPU tasks in this process.";
|
|
16
|
+
/**
|
|
17
|
+
* The site's apps, domains, realms and worker. `label` is the project's host label (D-0102):
|
|
18
|
+
* `--domains auto` gives every app `<app>.<label>.localhost`, unique to this project.
|
|
19
|
+
*/
|
|
20
|
+
export declare function siteDefinition(answers: Readonly<Record<string, Answer>>, label: string): {
|
|
21
|
+
apps: string[];
|
|
22
|
+
worker: string;
|
|
23
|
+
domains: Record<string, string>;
|
|
24
|
+
realms: Record<string, string>;
|
|
25
|
+
inertia: boolean;
|
|
26
|
+
ssr: boolean;
|
|
27
|
+
};
|
|
28
|
+
export declare function checkSite(root: string, answers: Readonly<Record<string, Answer>>, label: string): Promise<void>;
|
|
29
|
+
/** The auth extension the site recipe writes: one stub class per realm (exported for its tests). */
|
|
30
|
+
export declare function siteAuthSource(realms: string[]): string;
|
|
31
|
+
/** What the site scaffold's GET /login answers until the app writes its login (verify reads it). */
|
|
32
|
+
export declare const SITE_LOGIN_HINT = "Connect the realm extension to your user store and implement login with request.auth.attempt.";
|
|
33
|
+
/** The routes file of a site app (the scaffold's GET /login lives there). */
|
|
34
|
+
export declare const siteRoutesPath: (app: string) => string;
|
|
35
|
+
/**
|
|
36
|
+
* A browser app of the site (not api): its host is sealed (D-0092 §1, D-0062), EJS and Inertia
|
|
37
|
+
* alike. Server rendering cannot be sealed (SEAL_SSR_CONFLICT), so SSR apps stay plain and the
|
|
38
|
+
* recipe notice says so.
|
|
39
|
+
*/
|
|
40
|
+
export declare function sealedSiteApp(spec: ReturnType<typeof siteDefinition>, app: string): boolean;
|
|
41
|
+
/** Site reuses transactional install/app/config/file commands and the common recipe executor. */
|
|
42
|
+
export declare function siteSteps(catalog: Catalog, _recipe: CatalogRecipe, answers: Readonly<Record<string, Answer>>, context: {
|
|
43
|
+
root: string;
|
|
44
|
+
project: Project;
|
|
45
|
+
port: number;
|
|
46
|
+
init: boolean;
|
|
47
|
+
hostLabel: string;
|
|
48
|
+
}): Step[];
|