@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/verify.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// `dokebi verify` (D-0065): the one check an AI runs before it says "done". doctor → check
|
|
2
|
-
// (D-0072, user-code conventions) → typecheck → build (Vite projects) →
|
|
2
|
+
// (D-0072, user-code conventions) → typecheck → build (Vite projects) → test (the project's
|
|
3
|
+
// `test` script, a result per node:test test: D-0077 H, verify-tests.ts) → preflight → start →
|
|
3
4
|
// HTTP checks → stop; `ok` only when every check that ran passed. The app it starts is always
|
|
4
5
|
// stopped again.
|
|
5
6
|
//
|
|
@@ -16,20 +17,127 @@ import { join } from 'node:path';
|
|
|
16
17
|
import { setTimeout as delay } from 'node:timers/promises';
|
|
17
18
|
import { appUrls } from './app-urls.js';
|
|
18
19
|
import { check } from './check/index.js';
|
|
19
|
-
import { CONFIG_FILE, parseProjectConfig } from './configuration.js';
|
|
20
|
-
import { portInUse, URL_PLACEHOLDER_PREFIX } from './dev-infra.js';
|
|
21
|
-
import { debug, describeError } from './diagnostics.js';
|
|
20
|
+
import { CONFIG_FILE, configTarget, FRESH_COPY_FIX, parseProjectConfig, readNamedSettings, } from './configuration.js';
|
|
21
|
+
import { freePortAfter, portInUse, portQuestion, URL_PLACEHOLDER_PREFIX } from './dev-infra.js';
|
|
22
|
+
import { debug, describeError, isMissing, reportError } from './diagnostics.js';
|
|
23
|
+
import { envFileWarnings } from './env-file.js';
|
|
22
24
|
import { CliError } from './errors.js';
|
|
23
25
|
import { exists, parseObject, securePath } from './filesystem.js';
|
|
26
|
+
import { firstAdminPhrase, readFirstAdmin } from './first-admin.js';
|
|
24
27
|
import { findHost } from './manifest.js';
|
|
25
28
|
import { OutputTail } from './npm-client.js';
|
|
29
|
+
import { forwardTermination, killGroup, PROCESS_GROUPS } from './process-group.js';
|
|
26
30
|
import { ProjectInspector } from './project-inspector.js';
|
|
27
31
|
import { readState } from './project-state.js';
|
|
28
|
-
import {
|
|
32
|
+
import { factOf, factTexts, readProjectFacts } from './recipe-facts.js';
|
|
33
|
+
import { namesFirstAdmin, readAppliedRecipes, readRecordedChoices, TELL_USER_RELAY, tellUserLines, } from './recipe-record.js';
|
|
29
34
|
import * as server from './server.js';
|
|
35
|
+
import { probeUserStores } from './site-auth.js';
|
|
36
|
+
import { runTests, TEST_TIMEOUT_MS } from './verify-tests.js';
|
|
37
|
+
/**
|
|
38
|
+
* D-0105 §4: with @dokebi/db installed, what the agent's final message must contain besides
|
|
39
|
+
* tellUser. The guidance asks for it, and an AI consumer that read it many times still left it
|
|
40
|
+
* out (AI-M23, AI-M24): verify ok repeats it at the moment the agent is about to report.
|
|
41
|
+
*/
|
|
42
|
+
export function finalReportLines(ok, installed, allowed = []) {
|
|
43
|
+
if (!ok)
|
|
44
|
+
return [];
|
|
45
|
+
return [
|
|
46
|
+
...(installed.has('dokebi-db') ? [DB_PRACTICE_REPORT] : []),
|
|
47
|
+
...(allowed.length ? [allowedReport(allowed)] : []),
|
|
48
|
+
];
|
|
49
|
+
}
|
|
50
|
+
/** The opening of the silenced-rules report and the words before its list of markers. */
|
|
51
|
+
const SILENCED = 'Silenced check rules: ';
|
|
52
|
+
const LISTED = 'why the rule does not apply there: ';
|
|
53
|
+
/**
|
|
54
|
+
* R1 measurement: a builder printed only `{ ok, checks, tests, tellUser }` of the verify JSON and
|
|
55
|
+
* never saw `finalReport`, so its final message lacked the section 6 table. The tellUser lines,
|
|
56
|
+
* which every builder relays, end with what finalReport asks for (the field stays as it is).
|
|
57
|
+
*/
|
|
58
|
+
export function tellUserWithReport(tellUser, finalReport, dbRules = []) {
|
|
59
|
+
if (!tellUser.length || !finalReport.length)
|
|
60
|
+
return [...tellUser];
|
|
61
|
+
// R1 T5 review: the lines are relayed verbatim (relay), so each is said to the user: a heading
|
|
62
|
+
// the builder's table or list follows, never an instruction to the builder.
|
|
63
|
+
// R3 Codex T2: Codex reads only ok, checks and tellUser of the result (never relay or
|
|
64
|
+
// finalReport) and put the table in a file for the fifth time; the heading itself says where
|
|
65
|
+
// the table is, in words the user reads as a plain statement.
|
|
66
|
+
return [
|
|
67
|
+
...tellUser,
|
|
68
|
+
...finalReport.map((line) => line === DB_PRACTICE_REPORT
|
|
69
|
+
? `Database practice (@dokebi/db AI.md section 6): the table below, here in this message, has one row per rule of that section (${dbRules.length ? dbRules.join('; ') : 'each by its number, 6.1 onwards'}), with the queries or jobs it applies to, the measurement (rows read, rate, refusals) and the verdict (pass, or the fix you made).`
|
|
70
|
+
: line.startsWith(SILENCED)
|
|
71
|
+
? `Silenced check rules (dokebi-check allow markers), each followed below, here in this message, by why the rule does not apply there: ${line.slice(line.indexOf(LISTED) + LISTED.length).replace(/\. A marker on work[\s\S]*$/, '')}.`
|
|
72
|
+
: line),
|
|
73
|
+
];
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* R6 Codex T3: the heading said "one row per rule" and the builder, which reads only tellUser of
|
|
77
|
+
* the result and had read part of section 6, wrote a table of twelve rules of its own. The
|
|
78
|
+
* heading names the rules of the installed @dokebi/db AI.md (`### 6.N Title`), so each row has
|
|
79
|
+
* one to answer. No installed AI.md: none.
|
|
80
|
+
*/
|
|
81
|
+
export async function readDbPracticeRules(root) {
|
|
82
|
+
let text;
|
|
83
|
+
try {
|
|
84
|
+
text = await readFile(join(root, 'node_modules', '@dokebi', 'db', 'AI.md'), 'utf8');
|
|
85
|
+
}
|
|
86
|
+
catch (error) {
|
|
87
|
+
if (!isMissing(error))
|
|
88
|
+
throw error;
|
|
89
|
+
debug('verify: no @dokebi/db AI.md for the section 6 rules', { root });
|
|
90
|
+
return [];
|
|
91
|
+
}
|
|
92
|
+
return [...text.matchAll(/^### (6\.\d+ .+)$/gm)].map((match) => (match[1] ?? '').trim());
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* M34: a builder silenced background-timer on a database polling loop and check passed. verify ok
|
|
96
|
+
* asks the final message to list every silenced problem with its reason, so the person reading it
|
|
97
|
+
* sees each one (the markers stay legitimate for per-process upkeep).
|
|
98
|
+
*/
|
|
99
|
+
export function allowedReport(allowed) {
|
|
100
|
+
const listed = allowed
|
|
101
|
+
.slice(0, 20)
|
|
102
|
+
.map((marker) => `${marker.file}:${marker.line} ${marker.rule} (${marker.reason})`)
|
|
103
|
+
.join('; ');
|
|
104
|
+
const more = allowed.length > 20 ? `; and ${allowed.length - 20} more (npx dokebi check)` : '';
|
|
105
|
+
return `${SILENCED}your final message lists every dokebi-check allow marker of the project with its reason and ${LISTED}${listed}${more}. A marker on work that reads or writes the database (a polling or folding loop) is not a valid reason: move that work to a job or an outbox handler.`;
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* R2 Codex T2, T4, T5 (and R1 T1, T2): the builder relayed the headings verbatim, "the table below
|
|
109
|
+
* …", and put the table in a results file; it reads relay and tellUser, not finalReport. With
|
|
110
|
+
* finalReport lines, relay itself says that each heading's table or list follows it in the message.
|
|
111
|
+
* R4 Claude T4 copied the other lines and wrote its own headings for these two (`## Database
|
|
112
|
+
* practice (…)`): relay called them "a heading". They are lines to copy like the others.
|
|
113
|
+
*/
|
|
114
|
+
export const REPORT_RELAY = Object.freeze({
|
|
115
|
+
tellUser: 'verbatim',
|
|
116
|
+
instruction: `${TELL_USER_RELAY.instruction} The lines that start with Database practice and Silenced check rules are copied word for word too (they are lines of this list, not headings to write in your own words), and right under each of them you write the table or list it announces, in the same final message, as finalReport says; a results file or a link to one does not replace it.`,
|
|
117
|
+
});
|
|
118
|
+
export const DB_PRACTICE_REPORT = 'Database practice (@dokebi/db is installed): your final message contains a table with one row per rule of section 6 of node_modules/@dokebi/db/AI.md: the rule, the queries or jobs it applies to, the measurement (rows read, rate, refusals) and the verdict (pass, or the fix you made). A rule you did not measure is not done; a separate results file does not replace the table in the message.';
|
|
119
|
+
/**
|
|
120
|
+
* The preflight problem for a taken web port (AI-M04 F5), with what the recipe preflight gives:
|
|
121
|
+
* a free `alternative`, `waitForUser`, `question` and `next`. Who holds the port is not looked up
|
|
122
|
+
* (no `ps`); verify only knows it is not an app `dokebi start` runs for this project (that is
|
|
123
|
+
* ALREADY_RUNNING). `delegated`: the last recipe ran with --defaults (`.dokebi/recipe.json`).
|
|
124
|
+
*/
|
|
125
|
+
export async function webPortProblem(port, delegated) {
|
|
126
|
+
const alternative = await freePortAfter(port);
|
|
127
|
+
debug('verify: web port in use', { port, alternative, delegated });
|
|
128
|
+
return {
|
|
129
|
+
code: 'PORT_IN_USE',
|
|
130
|
+
message: `Port ${port} (options.dokebi-web.port) is in use by another program (not an app npx dokebi start runs for this project; maybe its npx dokebi dev, another project or another program)`,
|
|
131
|
+
fix: `ask the user, then npx dokebi config set dokebi-web.port=${alternative ?? '<free port>'} and verify again, or stop what uses ${port}`,
|
|
132
|
+
...(alternative ? { alternative } : {}),
|
|
133
|
+
...portQuestion({ what: 'Web', port, alternative, flag: 'dokebi-web.port', delegated }),
|
|
134
|
+
};
|
|
135
|
+
}
|
|
30
136
|
/**
|
|
31
137
|
* Runs an npm script of the project; resolves with its exit code and the bounded, redacted end
|
|
32
|
-
* of its output (stdout and stderr in arrival order, `OutputTail` of npm-client).
|
|
138
|
+
* of its output (stdout and stderr in arrival order, `OutputTail` of npm-client). The script runs
|
|
139
|
+
* in its own process group and the time limit kills the whole group: killing npm alone left tsc
|
|
140
|
+
* or Vite running, and the pipes they hold kept the step open until they ended by themselves.
|
|
33
141
|
*/
|
|
34
142
|
export async function npmScript(root, script, timeoutMs) {
|
|
35
143
|
const npmPath = process.env.npm_execpath;
|
|
@@ -37,7 +145,12 @@ export async function npmScript(root, script, timeoutMs) {
|
|
|
37
145
|
const args = [...(npmPath?.endsWith('npm-cli.js') ? [npmPath] : []), 'run', script];
|
|
38
146
|
debug('verify: running npm script', { script, root });
|
|
39
147
|
return new Promise((resolve, reject) => {
|
|
40
|
-
const child = spawn(command, args, {
|
|
148
|
+
const child = spawn(command, args, {
|
|
149
|
+
cwd: root,
|
|
150
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
151
|
+
detached: PROCESS_GROUPS,
|
|
152
|
+
});
|
|
153
|
+
const forwarding = forwardTermination(child.pid);
|
|
41
154
|
const output = new OutputTail();
|
|
42
155
|
const keep = (chunk) => output.push(chunk);
|
|
43
156
|
child.stdout.on('data', keep);
|
|
@@ -45,14 +158,17 @@ export async function npmScript(root, script, timeoutMs) {
|
|
|
45
158
|
let timedOut = false;
|
|
46
159
|
const timer = setTimeout(() => {
|
|
47
160
|
timedOut = true;
|
|
48
|
-
|
|
161
|
+
debug('verify: the npm script reached its time limit; killing it', { script, timeoutMs });
|
|
162
|
+
killGroup(child.pid, 'SIGKILL');
|
|
49
163
|
}, timeoutMs);
|
|
50
164
|
child.once('error', (cause) => {
|
|
51
165
|
clearTimeout(timer);
|
|
166
|
+
forwarding();
|
|
52
167
|
reject(new CliError('NPM_FAILED', `Could not run npm run ${script}`, { cause }));
|
|
53
168
|
});
|
|
54
169
|
child.once('close', (code) => {
|
|
55
170
|
clearTimeout(timer);
|
|
171
|
+
forwarding();
|
|
56
172
|
resolve({ code, output: output.text(), timedOut });
|
|
57
173
|
});
|
|
58
174
|
});
|
|
@@ -61,10 +177,31 @@ export async function npmScript(root, script, timeoutMs) {
|
|
|
61
177
|
export const VERIFY_TOKEN_ENV = 'DOKEBI_VERIFY_TOKEN';
|
|
62
178
|
/** The response header @dokebi/web answers with when that variable is set. */
|
|
63
179
|
export const VERIFY_HEADER = 'x-dokebi-verify';
|
|
64
|
-
/**
|
|
180
|
+
/**
|
|
181
|
+
* GET with a Host header (fetch cannot set one). Settles once: with the answer when the body
|
|
182
|
+
* ended, or with an error when the request fails, the response is aborted or its connection
|
|
183
|
+
* closes before the end (ISSUE-0006 A09), or nothing complete came within `timeoutMs` in all
|
|
184
|
+
* (the socket timeout alone never fires for a server that keeps trickling bytes).
|
|
185
|
+
*/
|
|
65
186
|
function get(url, host, timeoutMs = 5000) {
|
|
66
187
|
return new Promise((resolve, reject) => {
|
|
67
188
|
const target = new URL(url);
|
|
189
|
+
let settled = false;
|
|
190
|
+
const finish = (error, answer) => {
|
|
191
|
+
if (settled)
|
|
192
|
+
return;
|
|
193
|
+
settled = true;
|
|
194
|
+
clearTimeout(deadline);
|
|
195
|
+
if (error) {
|
|
196
|
+
// Frees the socket of a response that will never complete.
|
|
197
|
+
if (!req.destroyed)
|
|
198
|
+
req.destroy();
|
|
199
|
+
reject(error);
|
|
200
|
+
}
|
|
201
|
+
else
|
|
202
|
+
resolve(answer);
|
|
203
|
+
};
|
|
204
|
+
const failed = (error) => finish(error);
|
|
68
205
|
const req = request({
|
|
69
206
|
host: target.hostname.replace(/^\[|\]$/g, ''),
|
|
70
207
|
port: target.port,
|
|
@@ -73,27 +210,65 @@ function get(url, host, timeoutMs = 5000) {
|
|
|
73
210
|
timeout: timeoutMs,
|
|
74
211
|
}, (response) => {
|
|
75
212
|
response.resume();
|
|
76
|
-
response.once('end', () =>
|
|
213
|
+
response.once('end', () => finish(undefined, {
|
|
77
214
|
status: response.statusCode ?? 0,
|
|
78
215
|
type: String(response.headers['content-type'] ?? ''),
|
|
79
216
|
instance: response.headers[VERIFY_HEADER],
|
|
80
217
|
}));
|
|
218
|
+
response.once('aborted', () => finish(new Error('The response was aborted')));
|
|
219
|
+
// Error listeners stay after settling: a late error (the destroy below) is then ignored
|
|
220
|
+
// by `finish` instead of being an unhandled 'error' event.
|
|
221
|
+
response.on('error', failed);
|
|
222
|
+
response.once('close', () => {
|
|
223
|
+
if (!response.complete)
|
|
224
|
+
finish(new Error('The connection closed before the response ended'));
|
|
225
|
+
});
|
|
81
226
|
});
|
|
227
|
+
const deadline = setTimeout(() => finish(new Error(`No complete answer within ${timeoutMs} ms`)), timeoutMs);
|
|
82
228
|
req.once('timeout', () => req.destroy(new Error(`No answer within ${timeoutMs} ms`)));
|
|
83
|
-
req.
|
|
229
|
+
req.on('error', failed);
|
|
84
230
|
req.end();
|
|
85
231
|
});
|
|
86
232
|
}
|
|
233
|
+
/**
|
|
234
|
+
* A file verify needs before any check runs. A missing one is NOT_A_PROJECT with the file and a
|
|
235
|
+
* fix, never an INTERNAL_ERROR (a project copy without .dokebi/state.json, M32).
|
|
236
|
+
*/
|
|
237
|
+
async function projectFile(root, file) {
|
|
238
|
+
try {
|
|
239
|
+
return await readFile(join(root, file), 'utf8');
|
|
240
|
+
}
|
|
241
|
+
catch (error) {
|
|
242
|
+
if (!isMissing(error))
|
|
243
|
+
throw new CliError('READ_FAILED', `Cannot read ${file}`, { cause: error });
|
|
244
|
+
throw new CliError('NOT_A_PROJECT', `No ${file}: verify runs in a Dokebi project (the folder with package.json, dokebi.yaml and .dokebi/state.json)`, {
|
|
245
|
+
phase: 'arguments',
|
|
246
|
+
details: {
|
|
247
|
+
root,
|
|
248
|
+
file,
|
|
249
|
+
fix: file === '.dokebi/state.json'
|
|
250
|
+
? 'run verify in the project folder itself; .dokebi/state.json is written by the CLI and belongs in version control, so a copy needs it too (copy it from the project or check it out from git)'
|
|
251
|
+
: file === CONFIG_FILE
|
|
252
|
+
? `run verify in the project folder; ${FRESH_COPY_FIX}`
|
|
253
|
+
: 'run verify in the project folder (npx dokebi verify from the folder with dokebi.yaml)',
|
|
254
|
+
},
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
}
|
|
87
258
|
async function settings(root) {
|
|
88
|
-
const pkg = parseObject(await
|
|
259
|
+
const pkg = parseObject(await projectFile(root, 'package.json'), 'package.json');
|
|
89
260
|
let vite = false;
|
|
90
261
|
for (const file of ['vite.config.ts', 'vite.config.mts', 'vite.config.js', 'vite.config.mjs'])
|
|
91
262
|
vite ||= await exists(await securePath(root, file));
|
|
92
|
-
const state = readState(parseObject(await
|
|
263
|
+
const state = readState(parseObject(await projectFile(root, '.dokebi/state.json'), 'state'));
|
|
93
264
|
const ids = new Set(state.plugins.map((plugin) => plugin.id));
|
|
94
265
|
// The domains to check belong to the plugin that hosts apps (D-0070), whatever its id.
|
|
95
266
|
const host = findHost(state.plugins, 'apps');
|
|
96
|
-
|
|
267
|
+
// M50: the settings file the app reads (DOKEBI_CONFIG, else dokebi.yaml).
|
|
268
|
+
const target = await configTarget(root);
|
|
269
|
+
const options = parseProjectConfig(target.fromEnvironment
|
|
270
|
+
? await readNamedSettings(root, target)
|
|
271
|
+
: await projectFile(root, CONFIG_FILE)).options;
|
|
97
272
|
const web = (options['dokebi-web'] ?? {});
|
|
98
273
|
const app = ((host ? options[host.id] : undefined) ?? {});
|
|
99
274
|
const db = (options['dokebi-db'] ?? {});
|
|
@@ -123,18 +298,35 @@ async function settings(root) {
|
|
|
123
298
|
* The HTTP check of verify: GET the readiness path, then `/` of each app domain, at `base`. An
|
|
124
299
|
* answer counts only when it carries `token` in the x-dokebi-verify header (the instance verify
|
|
125
300
|
* started); anything else is retried like a failed request and then reported FOREIGN_SERVER.
|
|
301
|
+
* With neither a readiness path (`healthRoutes: false`) nor an app domain, one identity probe
|
|
302
|
+
* runs instead (ISSUE-0006 A08: no request at all used to pass): `GET /`, which passes with any
|
|
303
|
+
* status below 500 that carries the token, a 404 included (the app may have no route at `/`).
|
|
304
|
+
* Domain probes also accept 401/403 from this instance, reported as accessDenied: true.
|
|
305
|
+
* These prove reachability only; readiness still requires 2xx/3xx and login needs app tests.
|
|
306
|
+
* Each request must complete within `timeoutMs` (ISSUE-0006 A09).
|
|
126
307
|
*/
|
|
127
|
-
export async function httpChecks(base, paths, token, delayMs = 250) {
|
|
308
|
+
export async function httpChecks(base, paths, token, delayMs = 250, timeoutMs = 5000) {
|
|
128
309
|
const requests = [];
|
|
129
|
-
const probe = async (path, host, attempts) => {
|
|
310
|
+
const probe = async (path, host, attempts, identity = false) => {
|
|
130
311
|
const url = `${base}${path}`;
|
|
312
|
+
const kind = identity ? { identity: true } : {};
|
|
131
313
|
for (let attempt = 1;; attempt++) {
|
|
132
314
|
try {
|
|
133
|
-
const answer = await get(url, host);
|
|
315
|
+
const answer = await get(url, host, timeoutMs);
|
|
134
316
|
const instance = answer.instance === token ? 'ours' : 'other';
|
|
135
|
-
const
|
|
317
|
+
const accessDenied = host !== undefined && (answer.status === 401 || answer.status === 403);
|
|
318
|
+
const ok = ((answer.status >= 200 && answer.status < (identity ? 500 : 400)) || accessDenied) &&
|
|
319
|
+
instance === 'ours';
|
|
136
320
|
if (ok || attempt >= attempts) {
|
|
137
|
-
requests.push({
|
|
321
|
+
requests.push({
|
|
322
|
+
url,
|
|
323
|
+
...(host ? { host } : {}),
|
|
324
|
+
status: answer.status,
|
|
325
|
+
...(accessDenied ? { accessDenied: true } : {}),
|
|
326
|
+
ok,
|
|
327
|
+
instance,
|
|
328
|
+
...kind,
|
|
329
|
+
});
|
|
138
330
|
return;
|
|
139
331
|
}
|
|
140
332
|
if (instance === 'other')
|
|
@@ -151,6 +343,7 @@ export async function httpChecks(base, paths, token, delayMs = 250) {
|
|
|
151
343
|
status: 0,
|
|
152
344
|
ok: false,
|
|
153
345
|
error: error instanceof Error ? error.message : String(error),
|
|
346
|
+
...kind,
|
|
154
347
|
});
|
|
155
348
|
return;
|
|
156
349
|
}
|
|
@@ -163,6 +356,10 @@ export async function httpChecks(base, paths, token, delayMs = 250) {
|
|
|
163
356
|
await probe(paths.ready, undefined, 20);
|
|
164
357
|
for (const domain of paths.domains)
|
|
165
358
|
await probe('/', domain, 4);
|
|
359
|
+
if (!paths.ready && !paths.domains.length) {
|
|
360
|
+
debug('verify: no readiness route and no app domain; probing GET / for this run', { base });
|
|
361
|
+
await probe('/', undefined, 20, true);
|
|
362
|
+
}
|
|
166
363
|
const failed = requests.filter((item) => !item.ok);
|
|
167
364
|
const foreign = failed.filter((item) => item.instance === 'other');
|
|
168
365
|
return {
|
|
@@ -183,6 +380,38 @@ export async function httpChecks(base, paths, token, delayMs = 250) {
|
|
|
183
380
|
details: { requests },
|
|
184
381
|
};
|
|
185
382
|
}
|
|
383
|
+
const TERMINATING = ['SIGINT', 'SIGTERM', 'SIGHUP'];
|
|
384
|
+
/**
|
|
385
|
+
* While the app verify started runs: a SIGINT, SIGTERM or SIGHUP to verify (Ctrl+C, an agent's
|
|
386
|
+
* command time limit) stops that app first (stopStarted, forced after `limitMs`), then ends verify
|
|
387
|
+
* as the signal would have. Without it the background app outlived verify and held its port; the
|
|
388
|
+
* next verify then refused with ALREADY_RUNNING. Returns the function that removes the handlers.
|
|
389
|
+
*/
|
|
390
|
+
export function stopOnTermination(root, started, limitMs = 5000) {
|
|
391
|
+
const handlers = new Map();
|
|
392
|
+
const remove = () => {
|
|
393
|
+
for (const [signal, handler] of handlers)
|
|
394
|
+
process.off(signal, handler);
|
|
395
|
+
handlers.clear();
|
|
396
|
+
};
|
|
397
|
+
for (const signal of TERMINATING) {
|
|
398
|
+
const handler = () => {
|
|
399
|
+
remove();
|
|
400
|
+
debug('verify: terminated; stopping the app it started', { signal, pid: started.pid });
|
|
401
|
+
server
|
|
402
|
+
.stopStarted(root, started, limitMs, true)
|
|
403
|
+
.catch((error) => reportError('VERIFY_STOP_FAILED', 'Unable to stop the app verify started', error))
|
|
404
|
+
.finally(() => {
|
|
405
|
+
// The signal's default action (ending this process) applies again.
|
|
406
|
+
if (process.listenerCount(signal) === 0)
|
|
407
|
+
process.kill(process.pid, signal);
|
|
408
|
+
});
|
|
409
|
+
};
|
|
410
|
+
handlers.set(signal, handler);
|
|
411
|
+
process.on(signal, handler);
|
|
412
|
+
}
|
|
413
|
+
return remove;
|
|
414
|
+
}
|
|
186
415
|
export async function verify(root, options = {}) {
|
|
187
416
|
const checks = [];
|
|
188
417
|
const run = async (name, body) => {
|
|
@@ -210,6 +439,16 @@ export async function verify(root, options = {}) {
|
|
|
210
439
|
};
|
|
211
440
|
const skip = (reason) => async () => ({ ok: true, skipped: reason });
|
|
212
441
|
const project = await settings(root);
|
|
442
|
+
// An unreadable .env fails the steps that need it (test, start) with its code.
|
|
443
|
+
let warnings = [];
|
|
444
|
+
try {
|
|
445
|
+
warnings = envFileWarnings(root).map(({ code, path, hint }) => ({ code, path, hint }));
|
|
446
|
+
}
|
|
447
|
+
catch (error) {
|
|
448
|
+
if (!(error instanceof CliError))
|
|
449
|
+
throw error;
|
|
450
|
+
debug('verify: .env not read for its warnings', { code: error.code });
|
|
451
|
+
}
|
|
213
452
|
let inspection;
|
|
214
453
|
const doctor = await run('doctor', async () => {
|
|
215
454
|
const result = await new ProjectInspector().inspect(root, { runChecks: true });
|
|
@@ -225,8 +464,10 @@ export async function verify(root, options = {}) {
|
|
|
225
464
|
};
|
|
226
465
|
});
|
|
227
466
|
// D-0072: user-code conventions, on doctor's inspection (doctor is not run twice).
|
|
467
|
+
let allowed = [];
|
|
228
468
|
await run('check', async () => {
|
|
229
469
|
const result = await check(root, inspection ? { inspection } : {});
|
|
470
|
+
allowed = result.allowed;
|
|
230
471
|
return {
|
|
231
472
|
ok: result.ok,
|
|
232
473
|
...(result.ok
|
|
@@ -235,7 +476,12 @@ export async function verify(root, options = {}) {
|
|
|
235
476
|
code: 'CHECK_FAILED',
|
|
236
477
|
message: `${result.problems.length} convention problem(s): npx dokebi check`,
|
|
237
478
|
}),
|
|
238
|
-
details: {
|
|
479
|
+
details: {
|
|
480
|
+
problems: result.problems,
|
|
481
|
+
allowed: result.allowed,
|
|
482
|
+
files: result.files,
|
|
483
|
+
ms: result.ms,
|
|
484
|
+
},
|
|
239
485
|
};
|
|
240
486
|
});
|
|
241
487
|
const script = async (name, timeoutMs) => {
|
|
@@ -258,6 +504,17 @@ export async function verify(root, options = {}) {
|
|
|
258
504
|
await run('build', typeof project.scripts.build === 'string' && project.vite
|
|
259
505
|
? () => script('build', 300_000)
|
|
260
506
|
: skip('no Vite build in this project'));
|
|
507
|
+
// D-0077 H: the project's tests, after the build (they may need it) and before the preflight
|
|
508
|
+
// and the start (never next to the app: both may want the same port).
|
|
509
|
+
let tests;
|
|
510
|
+
await run('test', async () => {
|
|
511
|
+
const step = await runTests(root, project.scripts, {
|
|
512
|
+
timeoutMs: options.testTimeoutMs ?? TEST_TIMEOUT_MS,
|
|
513
|
+
});
|
|
514
|
+
const { tests: report, ...check } = step;
|
|
515
|
+
tests = report;
|
|
516
|
+
return check;
|
|
517
|
+
});
|
|
261
518
|
const preflight = await run('preflight', async () => {
|
|
262
519
|
const problems = [];
|
|
263
520
|
const running = await server.status(root);
|
|
@@ -268,11 +525,7 @@ export async function verify(root, options = {}) {
|
|
|
268
525
|
fix: 'npx dokebi stop',
|
|
269
526
|
});
|
|
270
527
|
else if (project.web.installed && project.web.port > 0 && (await portInUse(project.web.port)))
|
|
271
|
-
problems.push(
|
|
272
|
-
code: 'PORT_IN_USE',
|
|
273
|
-
message: `Port ${project.web.port} (options.dokebi-web.port) is in use`,
|
|
274
|
-
fix: 'stop what uses it, or npx dokebi config set dokebi-web.port=<free port>',
|
|
275
|
-
});
|
|
528
|
+
problems.push(await webPortProblem(project.web.port, (await readAppliedRecipes(root)).at(-1)?.answersFrom === 'defaults'));
|
|
276
529
|
for (const db of project.dbUrls)
|
|
277
530
|
if (db.missing)
|
|
278
531
|
problems.push({
|
|
@@ -290,6 +543,7 @@ export async function verify(root, options = {}) {
|
|
|
290
543
|
: { ok: true };
|
|
291
544
|
});
|
|
292
545
|
let started;
|
|
546
|
+
let release;
|
|
293
547
|
// Only the instance started here echoes this token (x-dokebi-verify, @dokebi/web).
|
|
294
548
|
const token = randomBytes(16).toString('hex');
|
|
295
549
|
const reason = !doctor.ok ? 'doctor failed' : !preflight.ok ? 'preflight failed' : undefined;
|
|
@@ -300,6 +554,7 @@ export async function verify(root, options = {}) {
|
|
|
300
554
|
waitMs: options.waitMs ?? 30_000,
|
|
301
555
|
env: { [VERIFY_TOKEN_ENV]: token },
|
|
302
556
|
});
|
|
557
|
+
release = stopOnTermination(root, started);
|
|
303
558
|
return {
|
|
304
559
|
ok: started.ready,
|
|
305
560
|
...(started.ready ? {} : { code: 'NOT_READY', message: started.warnings.join('; ') }),
|
|
@@ -366,15 +621,58 @@ export async function verify(root, options = {}) {
|
|
|
366
621
|
};
|
|
367
622
|
}
|
|
368
623
|
: skip('nothing was started'));
|
|
624
|
+
release?.();
|
|
369
625
|
}
|
|
370
626
|
const ok = checks.every((check) => check.ok);
|
|
371
|
-
const
|
|
627
|
+
const asked = (preflight.details?.problems ?? []).find((item) => item.question);
|
|
372
628
|
const shown = started ? listen : [];
|
|
629
|
+
const urls = shown.length ? appUrls(shown, await server.appHostsOrNone(root)) : [];
|
|
630
|
+
// The notices name the app's address as it is now (AI-M05: the port may have changed since),
|
|
631
|
+
// the first admin as dokebi.yaml and the seed configure it now (D-0115), and every fact they
|
|
632
|
+
// state as the project has it now (D-0137): the app verify started was ready, so each job
|
|
633
|
+
// queue it configures was bound.
|
|
634
|
+
const recipes = ok ? await readAppliedRecipes(root) : [];
|
|
635
|
+
let firstAdmin;
|
|
636
|
+
const readAdmin = () => {
|
|
637
|
+
firstAdmin ??= readFirstAdmin(root);
|
|
638
|
+
return firstAdmin;
|
|
639
|
+
};
|
|
640
|
+
const facts = recipes.some((entry) => entry.notices.some((notice) => factOf(notice)))
|
|
641
|
+
? await factTexts(recipes, await readProjectFacts(root, {
|
|
642
|
+
started: Boolean(started?.ready),
|
|
643
|
+
firstAdmin: readAdmin,
|
|
644
|
+
userStores: (realms) => probeUserStores(root, realms),
|
|
645
|
+
}))
|
|
646
|
+
: undefined;
|
|
647
|
+
const tellUser = ok
|
|
648
|
+
? tellUserLines(recipes, {
|
|
649
|
+
urls,
|
|
650
|
+
...(project.web.port > 0 ? { port: project.web.port } : {}),
|
|
651
|
+
},
|
|
652
|
+
// D-0098: the views of apps made outside a recipe, with who chose them.
|
|
653
|
+
await readRecordedChoices(root), recipes.some((entry) => entry.notices.some(namesFirstAdmin))
|
|
654
|
+
? firstAdminPhrase(await readAdmin())
|
|
655
|
+
: undefined, facts)
|
|
656
|
+
: [];
|
|
657
|
+
const finalReport = finalReportLines(ok, new Set(inspection?.plugins.flatMap((item) => item.installed?.id ?? []) ?? []), allowed);
|
|
658
|
+
if (finalReport.length)
|
|
659
|
+
debug('verify: final report reminders', { lines: finalReport.length });
|
|
373
660
|
return {
|
|
374
661
|
ok,
|
|
375
662
|
checks,
|
|
376
|
-
urls
|
|
663
|
+
urls,
|
|
377
664
|
listen: shown,
|
|
378
|
-
...(
|
|
665
|
+
...(tests ? { tests } : {}),
|
|
666
|
+
...(tellUser.length
|
|
667
|
+
? {
|
|
668
|
+
tellUser: tellUserWithReport(tellUser, finalReport, finalReport.includes(DB_PRACTICE_REPORT) ? await readDbPracticeRules(root) : []),
|
|
669
|
+
relay: finalReport.length ? REPORT_RELAY : TELL_USER_RELAY,
|
|
670
|
+
}
|
|
671
|
+
: {}),
|
|
672
|
+
...(finalReport.length ? { finalReport } : {}),
|
|
673
|
+
...(asked?.question
|
|
674
|
+
? { waitForUser: asked.waitForUser ?? true, question: asked.question, next: asked.next ?? '' }
|
|
675
|
+
: {}),
|
|
676
|
+
...(warnings.length ? { warnings } : {}),
|
|
379
677
|
};
|
|
380
678
|
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import type { RecordedChoice } from './recipe-record.js';
|
|
2
|
+
/**
|
|
3
|
+
* The longest `--ai-reason` text, one line for the user like a catalog defaultReason: of a recipe
|
|
4
|
+
* answer (D-0095) and of the view `app add` records (D-0098). One rule, `aiReasonFits`, for both.
|
|
5
|
+
*/
|
|
6
|
+
export declare const AI_REASON_MAX = 200;
|
|
7
|
+
/** The id `--ai-chose` and `--ai-reason` name on `app add`. */
|
|
8
|
+
export declare const VIEW_ID = "view";
|
|
9
|
+
/** The view answer of a project without browser screens (recipe.ts NO_SCREENS), and of `--view none`. */
|
|
10
|
+
export declare const NO_VIEW = "none";
|
|
11
|
+
/** What `app add --view` takes: EJS pages, or no views (a JSON API). */
|
|
12
|
+
export type AppView = 'ejs' | 'none';
|
|
13
|
+
/**
|
|
14
|
+
* The view the project's latest recipe recorded (`view` answer), if any: `none` for the api
|
|
15
|
+
* recipe, so `app add` makes an app without views there unless a flag says otherwise.
|
|
16
|
+
*/
|
|
17
|
+
export declare function projectView(root: string): Promise<string | undefined>;
|
|
18
|
+
/** Who chose the view, as `app add` was told (`--answers-from`, `--ai-chose`, `--ai-reason`). */
|
|
19
|
+
export interface ViewSourceFlags {
|
|
20
|
+
readonly answersFrom?: 'user' | 'defaults';
|
|
21
|
+
readonly aiChose?: readonly string[];
|
|
22
|
+
readonly aiReasons?: Readonly<Record<string, string>>;
|
|
23
|
+
}
|
|
24
|
+
/** A trimmed `--ai-reason` text is one line of 1 to AI_REASON_MAX characters. */
|
|
25
|
+
export declare function aiReasonFits(reason: string): boolean;
|
|
26
|
+
/** The value of the view question for an app made with these flags. */
|
|
27
|
+
export declare function appViewValue(inertia: boolean, ssr: boolean, none?: boolean): string;
|
|
28
|
+
/**
|
|
29
|
+
* Checks the source flags of `app add` before anything changes: `--ai-chose` names only `view`,
|
|
30
|
+
* `--ai-reason` only an id of `--ai-chose`, and a reason is one line of 1 to 200 characters.
|
|
31
|
+
*/
|
|
32
|
+
export declare function checkViewSource(flags: ViewSourceFlags): void;
|
|
33
|
+
/**
|
|
34
|
+
* The record of an app's view: `ai` with `--ai-chose view` (or `--answers-from defaults`),
|
|
35
|
+
* `user` with `--answers-from user`, `unstated` for an EJS app or one without views, without
|
|
36
|
+
* either (not the default, so the user hears about it); none for an Inertia app without either
|
|
37
|
+
* (the default), nor for an app without views the project's recipe implied (`inherited`: the
|
|
38
|
+
* recipe's own line already says who chose no screens).
|
|
39
|
+
*/
|
|
40
|
+
export declare function appViewChoice(app: string, value: string, flags: ViewSourceFlags, now?: Date, inherited?: boolean): RecordedChoice | undefined;
|
|
41
|
+
/** The warning of an EJS app (or one without views) whose chooser was not stated (D-0098). */
|
|
42
|
+
export declare function unstatedViewWarning(app: string, value?: string): {
|
|
43
|
+
code: 'VIEW_SOURCE_UNSTATED';
|
|
44
|
+
path: string;
|
|
45
|
+
hint: string;
|
|
46
|
+
};
|
|
47
|
+
/**
|
|
48
|
+
* After `app add` (or `add` with the first app): records the app's view with its source and adds
|
|
49
|
+
* VIEW_SOURCE_UNSTATED to the result's warnings for an EJS app without a source. A dry run only
|
|
50
|
+
* warns. The result is the command's own; it is returned with the warning added.
|
|
51
|
+
*/
|
|
52
|
+
export declare function recordAppView<T extends {
|
|
53
|
+
warnings?: object[];
|
|
54
|
+
appView?: {
|
|
55
|
+
value: string;
|
|
56
|
+
from: 'flags' | 'recipe';
|
|
57
|
+
};
|
|
58
|
+
}>(root: string, result: T, app: string, value: string, flags: ViewSourceFlags, dryRun: boolean): Promise<T>;
|