@dokebi/cli 0.2.5 → 0.4.1
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 +181 -87
- package/README.md +55 -24
- package/catalog.json +2203 -0
- package/dist/app-urls.d.ts +21 -0
- package/dist/app-urls.js +94 -0
- package/dist/banner-art.d.ts +4 -0
- package/dist/banner-art.js +31 -0
- package/dist/banner.d.ts +25 -0
- package/dist/banner.js +202 -0
- package/dist/bin.d.ts +2 -1
- package/dist/bin.js +475 -46
- package/dist/catalog.d.ts +161 -0
- package/dist/catalog.js +110 -0
- package/dist/check/form-rules.d.ts +13 -0
- package/dist/check/form-rules.js +224 -0
- package/dist/check/index.d.ts +23 -0
- package/dist/check/index.js +70 -0
- package/dist/check/packages.d.ts +17 -0
- package/dist/check/packages.js +161 -0
- package/dist/check/problems.d.ts +19 -0
- package/dist/check/problems.js +22 -0
- package/dist/check/project-rules.d.ts +10 -0
- package/dist/check/project-rules.js +142 -0
- package/dist/check/source-rules.d.ts +28 -0
- package/dist/check/source-rules.js +273 -0
- package/dist/check/typescript.d.ts +46 -0
- package/dist/check/typescript.js +168 -0
- package/dist/commands.d.ts +18 -0
- package/dist/commands.js +372 -0
- package/dist/config-edit.d.ts +5 -4
- package/dist/config-edit.js +9 -8
- package/dist/configuration.d.ts +1 -0
- package/dist/configuration.js +2 -1
- package/dist/dev-infra.d.ts +62 -0
- package/dist/dev-infra.js +215 -0
- package/dist/env-file.d.ts +24 -0
- package/dist/env-file.js +100 -0
- package/dist/generator.d.ts +24 -1
- package/dist/generator.js +57 -47
- package/dist/guide.d.ts +29 -0
- package/dist/guide.js +90 -0
- package/dist/install-plan.d.ts +8 -0
- package/dist/install-plan.js +21 -4
- package/dist/layout.d.ts +5 -4
- package/dist/layout.js +8 -7
- package/dist/log-writer-control.d.ts +55 -0
- package/dist/log-writer-control.js +327 -0
- package/dist/log-writer.js +53 -12
- package/dist/manifest.d.ts +28 -0
- package/dist/manifest.js +77 -2
- package/dist/npm-client.d.ts +49 -0
- package/dist/npm-client.js +162 -41
- package/dist/output.d.ts +1 -1
- package/dist/output.js +14 -8
- package/dist/plugin-lifecycle.d.ts +7 -0
- package/dist/plugin-lifecycle.js +21 -3
- package/dist/plugins-command.d.ts +47 -0
- package/dist/plugins-command.js +189 -0
- package/dist/project-guide.d.ts +36 -1
- package/dist/project-guide.js +142 -18
- package/dist/project-inspector.d.ts +10 -0
- package/dist/project-inspector.js +51 -2
- package/dist/project-state.d.ts +13 -2
- package/dist/project-state.js +8 -6
- package/dist/project.d.ts +89 -7
- package/dist/project.js +835 -85
- package/dist/recipe-record.d.ts +24 -0
- package/dist/recipe-record.js +67 -0
- package/dist/recipe.d.ts +120 -0
- package/dist/recipe.js +801 -0
- package/dist/registry.d.ts +79 -0
- package/dist/registry.js +212 -0
- package/dist/rotating-log.d.ts +12 -0
- package/dist/rotating-log.js +19 -0
- package/dist/server.d.ts +94 -4
- package/dist/server.js +624 -64
- package/dist/verify.d.ts +62 -0
- package/dist/verify.js +380 -0
- package/dist/vite-host-process.d.ts +20 -0
- package/dist/vite-host-process.js +117 -0
- package/package.json +30 -6
package/dist/verify.d.ts
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
export interface VerifyCheck {
|
|
2
|
+
readonly name: 'doctor' | 'check' | 'typecheck' | 'build' | 'preflight' | 'start' | 'http' | 'stop';
|
|
3
|
+
readonly ok: boolean;
|
|
4
|
+
readonly skipped?: string;
|
|
5
|
+
readonly ms: number;
|
|
6
|
+
/** Why it failed, a code to look up in AI.md. */
|
|
7
|
+
readonly code?: string;
|
|
8
|
+
readonly message?: string;
|
|
9
|
+
readonly details?: unknown;
|
|
10
|
+
}
|
|
11
|
+
export interface VerifyResult {
|
|
12
|
+
readonly ok: boolean;
|
|
13
|
+
readonly checks: VerifyCheck[];
|
|
14
|
+
/**
|
|
15
|
+
* Where the app answers (AI-M03): with @dokebi/app, `<scheme>://<domain>:<port>` for each
|
|
16
|
+
* configured loopback domain (`localhost`, `*.localhost`, `127.x.x.x`); without it, `listen`.
|
|
17
|
+
*/
|
|
18
|
+
readonly urls: string[];
|
|
19
|
+
/** The listening addresses (the web listening line, else dokebi.yaml); HTTP checks connect here. */
|
|
20
|
+
readonly listen: string[];
|
|
21
|
+
/**
|
|
22
|
+
* Only when ok and a recipe was applied (`.dokebi/recipe.json`): lines the agent's final
|
|
23
|
+
* message to the user must include — each choice and who made it (a default with its reason)
|
|
24
|
+
* and the notices, such as no CSRF protection for HTML forms (AI-M03).
|
|
25
|
+
*/
|
|
26
|
+
readonly tellUser?: string[];
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Runs an npm script of the project; resolves with its exit code and the bounded, redacted end
|
|
30
|
+
* of its output (stdout and stderr in arrival order, `OutputTail` of npm-client).
|
|
31
|
+
*/
|
|
32
|
+
export declare function npmScript(root: string, script: string, timeoutMs: number): Promise<{
|
|
33
|
+
code: number | null;
|
|
34
|
+
output: string;
|
|
35
|
+
timedOut: boolean;
|
|
36
|
+
}>;
|
|
37
|
+
/** The environment variable verify sets on the app it starts; @dokebi/web reads it. */
|
|
38
|
+
export declare const VERIFY_TOKEN_ENV = "DOKEBI_VERIFY_TOKEN";
|
|
39
|
+
/** The response header @dokebi/web answers with when that variable is set. */
|
|
40
|
+
export declare const VERIFY_HEADER = "x-dokebi-verify";
|
|
41
|
+
/** One HTTP request of the verify check. */
|
|
42
|
+
export interface HttpProbe {
|
|
43
|
+
readonly url: string;
|
|
44
|
+
readonly host?: string;
|
|
45
|
+
readonly status: number;
|
|
46
|
+
readonly ok: boolean;
|
|
47
|
+
/** `other`: an answer without this run's token header — another server on the port. */
|
|
48
|
+
readonly instance?: 'ours' | 'other';
|
|
49
|
+
readonly error?: string;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* The HTTP check of verify: GET the readiness path, then `/` of each app domain, at `base`. An
|
|
53
|
+
* answer counts only when it carries `token` in the x-dokebi-verify header (the instance verify
|
|
54
|
+
* started); anything else is retried like a failed request and then reported FOREIGN_SERVER.
|
|
55
|
+
*/
|
|
56
|
+
export declare function httpChecks(base: string, paths: {
|
|
57
|
+
ready: string | null;
|
|
58
|
+
domains: readonly string[];
|
|
59
|
+
}, token: string, delayMs?: number): Promise<Omit<VerifyCheck, 'name' | 'ms'>>;
|
|
60
|
+
export declare function verify(root: string, options?: {
|
|
61
|
+
waitMs?: number;
|
|
62
|
+
}): Promise<VerifyResult>;
|
package/dist/verify.js
ADDED
|
@@ -0,0 +1,380 @@
|
|
|
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) → preflight → start →
|
|
3
|
+
// HTTP checks → stop; `ok` only when every check that ran passed. The app it starts is always
|
|
4
|
+
// stopped again.
|
|
5
|
+
//
|
|
6
|
+
// D-0065 §11 (AI-M02): the HTTP checks count only answers from the instance verify started. The
|
|
7
|
+
// app gets a random token in DOKEBI_VERIFY_TOKEN and @dokebi/web echoes it in the
|
|
8
|
+
// `x-dokebi-verify` response header (only when that variable is set: nothing in production). An
|
|
9
|
+
// answer without it is another server on the port (FOREIGN_SERVER). When the app did not start,
|
|
10
|
+
// the HTTP check is skipped and failed. The stop uses the pids `start` returned (no `ps`).
|
|
11
|
+
import { spawn } from 'node:child_process';
|
|
12
|
+
import { randomBytes } from 'node:crypto';
|
|
13
|
+
import { readFile } from 'node:fs/promises';
|
|
14
|
+
import { request } from 'node:http';
|
|
15
|
+
import { join } from 'node:path';
|
|
16
|
+
import { setTimeout as delay } from 'node:timers/promises';
|
|
17
|
+
import { appUrls } from './app-urls.js';
|
|
18
|
+
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';
|
|
22
|
+
import { CliError } from './errors.js';
|
|
23
|
+
import { exists, parseObject, securePath } from './filesystem.js';
|
|
24
|
+
import { findHost } from './manifest.js';
|
|
25
|
+
import { OutputTail } from './npm-client.js';
|
|
26
|
+
import { ProjectInspector } from './project-inspector.js';
|
|
27
|
+
import { readState } from './project-state.js';
|
|
28
|
+
import { readAppliedRecipes, tellUserLines } from './recipe-record.js';
|
|
29
|
+
import * as server from './server.js';
|
|
30
|
+
/**
|
|
31
|
+
* 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).
|
|
33
|
+
*/
|
|
34
|
+
export async function npmScript(root, script, timeoutMs) {
|
|
35
|
+
const npmPath = process.env.npm_execpath;
|
|
36
|
+
const command = npmPath?.endsWith('npm-cli.js') ? process.execPath : 'npm';
|
|
37
|
+
const args = [...(npmPath?.endsWith('npm-cli.js') ? [npmPath] : []), 'run', script];
|
|
38
|
+
debug('verify: running npm script', { script, root });
|
|
39
|
+
return new Promise((resolve, reject) => {
|
|
40
|
+
const child = spawn(command, args, { cwd: root, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
41
|
+
const output = new OutputTail();
|
|
42
|
+
const keep = (chunk) => output.push(chunk);
|
|
43
|
+
child.stdout.on('data', keep);
|
|
44
|
+
child.stderr.on('data', keep);
|
|
45
|
+
let timedOut = false;
|
|
46
|
+
const timer = setTimeout(() => {
|
|
47
|
+
timedOut = true;
|
|
48
|
+
child.kill('SIGKILL');
|
|
49
|
+
}, timeoutMs);
|
|
50
|
+
child.once('error', (cause) => {
|
|
51
|
+
clearTimeout(timer);
|
|
52
|
+
reject(new CliError('NPM_FAILED', `Could not run npm run ${script}`, { cause }));
|
|
53
|
+
});
|
|
54
|
+
child.once('close', (code) => {
|
|
55
|
+
clearTimeout(timer);
|
|
56
|
+
resolve({ code, output: output.text(), timedOut });
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
/** The environment variable verify sets on the app it starts; @dokebi/web reads it. */
|
|
61
|
+
export const VERIFY_TOKEN_ENV = 'DOKEBI_VERIFY_TOKEN';
|
|
62
|
+
/** The response header @dokebi/web answers with when that variable is set. */
|
|
63
|
+
export const VERIFY_HEADER = 'x-dokebi-verify';
|
|
64
|
+
/** GET with a Host header (fetch cannot set one). */
|
|
65
|
+
function get(url, host, timeoutMs = 5000) {
|
|
66
|
+
return new Promise((resolve, reject) => {
|
|
67
|
+
const target = new URL(url);
|
|
68
|
+
const req = request({
|
|
69
|
+
host: target.hostname.replace(/^\[|\]$/g, ''),
|
|
70
|
+
port: target.port,
|
|
71
|
+
path: `${target.pathname}${target.search}`,
|
|
72
|
+
headers: host ? { host } : {},
|
|
73
|
+
timeout: timeoutMs,
|
|
74
|
+
}, (response) => {
|
|
75
|
+
response.resume();
|
|
76
|
+
response.once('end', () => resolve({
|
|
77
|
+
status: response.statusCode ?? 0,
|
|
78
|
+
type: String(response.headers['content-type'] ?? ''),
|
|
79
|
+
instance: response.headers[VERIFY_HEADER],
|
|
80
|
+
}));
|
|
81
|
+
});
|
|
82
|
+
req.once('timeout', () => req.destroy(new Error(`No answer within ${timeoutMs} ms`)));
|
|
83
|
+
req.once('error', reject);
|
|
84
|
+
req.end();
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
async function settings(root) {
|
|
88
|
+
const pkg = parseObject(await readFile(join(root, 'package.json'), 'utf8'), 'package.json');
|
|
89
|
+
let vite = false;
|
|
90
|
+
for (const file of ['vite.config.ts', 'vite.config.mts', 'vite.config.js', 'vite.config.mjs'])
|
|
91
|
+
vite ||= await exists(await securePath(root, file));
|
|
92
|
+
const state = readState(parseObject(await readFile(join(root, '.dokebi/state.json'), 'utf8'), 'state'));
|
|
93
|
+
const ids = new Set(state.plugins.map((plugin) => plugin.id));
|
|
94
|
+
// The domains to check belong to the plugin that hosts apps (D-0070), whatever its id.
|
|
95
|
+
const host = findHost(state.plugins, 'apps');
|
|
96
|
+
const options = parseProjectConfig(await readFile(join(root, CONFIG_FILE), 'utf8')).options;
|
|
97
|
+
const web = (options['dokebi-web'] ?? {});
|
|
98
|
+
const app = ((host ? options[host.id] : undefined) ?? {});
|
|
99
|
+
const db = (options['dokebi-db'] ?? {});
|
|
100
|
+
return {
|
|
101
|
+
scripts: (pkg.scripts ?? {}),
|
|
102
|
+
vite,
|
|
103
|
+
web: {
|
|
104
|
+
installed: ids.has('dokebi-web'),
|
|
105
|
+
port: typeof web.port === 'number' ? web.port : 0,
|
|
106
|
+
host: typeof web.host === 'string' ? web.host : '127.0.0.1',
|
|
107
|
+
ready: web.healthRoutes === false ? null : (web.healthRoutes?.ready ?? '/health/ready'),
|
|
108
|
+
},
|
|
109
|
+
domains: host ? Object.keys(app.domains ?? {}) : [],
|
|
110
|
+
dbUrls: ids.has('dokebi-db')
|
|
111
|
+
? Object.entries(db.connections ?? {}).map(([name, connection]) => {
|
|
112
|
+
const url = connection?.options?.url;
|
|
113
|
+
return {
|
|
114
|
+
name,
|
|
115
|
+
missing: url !== undefined &&
|
|
116
|
+
(typeof url !== 'string' || url.startsWith(URL_PLACEHOLDER_PREFIX)),
|
|
117
|
+
};
|
|
118
|
+
})
|
|
119
|
+
: [],
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* The HTTP check of verify: GET the readiness path, then `/` of each app domain, at `base`. An
|
|
124
|
+
* answer counts only when it carries `token` in the x-dokebi-verify header (the instance verify
|
|
125
|
+
* started); anything else is retried like a failed request and then reported FOREIGN_SERVER.
|
|
126
|
+
*/
|
|
127
|
+
export async function httpChecks(base, paths, token, delayMs = 250) {
|
|
128
|
+
const requests = [];
|
|
129
|
+
const probe = async (path, host, attempts) => {
|
|
130
|
+
const url = `${base}${path}`;
|
|
131
|
+
for (let attempt = 1;; attempt++) {
|
|
132
|
+
try {
|
|
133
|
+
const answer = await get(url, host);
|
|
134
|
+
const instance = answer.instance === token ? 'ours' : 'other';
|
|
135
|
+
const ok = answer.status >= 200 && answer.status < 400 && instance === 'ours';
|
|
136
|
+
if (ok || attempt >= attempts) {
|
|
137
|
+
requests.push({ url, ...(host ? { host } : {}), status: answer.status, ok, instance });
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
if (instance === 'other')
|
|
141
|
+
debug('verify: an answer without the token of this run; retrying', {
|
|
142
|
+
url,
|
|
143
|
+
status: answer.status,
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
catch (error) {
|
|
147
|
+
if (attempt >= attempts) {
|
|
148
|
+
requests.push({
|
|
149
|
+
url,
|
|
150
|
+
...(host ? { host } : {}),
|
|
151
|
+
status: 0,
|
|
152
|
+
ok: false,
|
|
153
|
+
error: error instanceof Error ? error.message : String(error),
|
|
154
|
+
});
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
debug('verify: request failed, retrying', { url, error: describeError(error) });
|
|
158
|
+
}
|
|
159
|
+
await delay(delayMs);
|
|
160
|
+
}
|
|
161
|
+
};
|
|
162
|
+
if (paths.ready)
|
|
163
|
+
await probe(paths.ready, undefined, 20);
|
|
164
|
+
for (const domain of paths.domains)
|
|
165
|
+
await probe('/', domain, 4);
|
|
166
|
+
const failed = requests.filter((item) => !item.ok);
|
|
167
|
+
const foreign = failed.filter((item) => item.instance === 'other');
|
|
168
|
+
return {
|
|
169
|
+
ok: failed.length === 0,
|
|
170
|
+
...(foreign.length
|
|
171
|
+
? {
|
|
172
|
+
code: 'FOREIGN_SERVER',
|
|
173
|
+
message: `Another server answered on ${base} (no ${VERIFY_HEADER} header of this run): the app verify started is not the one listening there; stop the other app or choose another port (npx dokebi config set dokebi-web.port=<free port>)`,
|
|
174
|
+
}
|
|
175
|
+
: failed.length
|
|
176
|
+
? {
|
|
177
|
+
code: 'HTTP_FAILED',
|
|
178
|
+
message: failed
|
|
179
|
+
.map((item) => `${item.host ?? ''}${item.url} → ${item.status || item.error}`)
|
|
180
|
+
.join('; '),
|
|
181
|
+
}
|
|
182
|
+
: {}),
|
|
183
|
+
details: { requests },
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
export async function verify(root, options = {}) {
|
|
187
|
+
const checks = [];
|
|
188
|
+
const run = async (name, body) => {
|
|
189
|
+
const began = performance.now();
|
|
190
|
+
let check;
|
|
191
|
+
try {
|
|
192
|
+
check = { name, ...(await body()), ms: Math.round(performance.now() - began) };
|
|
193
|
+
}
|
|
194
|
+
catch (error) {
|
|
195
|
+
// A check that throws is a failed check with its code; the others still report.
|
|
196
|
+
check = {
|
|
197
|
+
name,
|
|
198
|
+
ok: false,
|
|
199
|
+
ms: Math.round(performance.now() - began),
|
|
200
|
+
code: error instanceof CliError ? error.code : 'CHECK_ERROR',
|
|
201
|
+
message: error instanceof Error ? error.message : String(error),
|
|
202
|
+
...(error instanceof CliError && error.details !== undefined
|
|
203
|
+
? { details: error.details }
|
|
204
|
+
: { details: { error: describeError(error) } }),
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
debug('verify: check finished', { name, ok: check.ok, skipped: check.skipped ?? null });
|
|
208
|
+
checks.push(check);
|
|
209
|
+
return check;
|
|
210
|
+
};
|
|
211
|
+
const skip = (reason) => async () => ({ ok: true, skipped: reason });
|
|
212
|
+
const project = await settings(root);
|
|
213
|
+
let inspection;
|
|
214
|
+
const doctor = await run('doctor', async () => {
|
|
215
|
+
const result = await new ProjectInspector().inspect(root, { runChecks: true });
|
|
216
|
+
inspection = result;
|
|
217
|
+
const errors = result.diagnostics.filter((item) => item.severity === 'error');
|
|
218
|
+
return {
|
|
219
|
+
ok: result.healthy,
|
|
220
|
+
...(result.healthy ? {} : { code: 'DOCTOR_FAILED', message: 'npx dokebi doctor --json' }),
|
|
221
|
+
details: {
|
|
222
|
+
errors,
|
|
223
|
+
warnings: result.diagnostics.filter((item) => item.severity === 'warning').length,
|
|
224
|
+
},
|
|
225
|
+
};
|
|
226
|
+
});
|
|
227
|
+
// D-0072: user-code conventions, on doctor's inspection (doctor is not run twice).
|
|
228
|
+
await run('check', async () => {
|
|
229
|
+
const result = await check(root, inspection ? { inspection } : {});
|
|
230
|
+
return {
|
|
231
|
+
ok: result.ok,
|
|
232
|
+
...(result.ok
|
|
233
|
+
? {}
|
|
234
|
+
: {
|
|
235
|
+
code: 'CHECK_FAILED',
|
|
236
|
+
message: `${result.problems.length} convention problem(s): npx dokebi check`,
|
|
237
|
+
}),
|
|
238
|
+
details: { problems: result.problems, files: result.files, ms: result.ms },
|
|
239
|
+
};
|
|
240
|
+
});
|
|
241
|
+
const script = async (name, timeoutMs) => {
|
|
242
|
+
const result = await npmScript(root, name, timeoutMs);
|
|
243
|
+
const ok = result.code === 0 && !result.timedOut;
|
|
244
|
+
return {
|
|
245
|
+
ok,
|
|
246
|
+
...(ok
|
|
247
|
+
? {}
|
|
248
|
+
: {
|
|
249
|
+
code: name === 'build' ? 'BUILD_FAILED' : 'TYPECHECK_FAILED',
|
|
250
|
+
message: result.timedOut ? `npm run ${name} timed out` : `npm run ${name} failed`,
|
|
251
|
+
details: { exitCode: result.code, output: result.output },
|
|
252
|
+
}),
|
|
253
|
+
};
|
|
254
|
+
};
|
|
255
|
+
await run('typecheck', typeof project.scripts.typecheck === 'string'
|
|
256
|
+
? () => script('typecheck', 300_000)
|
|
257
|
+
: skip('package.json has no typecheck script'));
|
|
258
|
+
await run('build', typeof project.scripts.build === 'string' && project.vite
|
|
259
|
+
? () => script('build', 300_000)
|
|
260
|
+
: skip('no Vite build in this project'));
|
|
261
|
+
const preflight = await run('preflight', async () => {
|
|
262
|
+
const problems = [];
|
|
263
|
+
const running = await server.status(root);
|
|
264
|
+
if (running.running)
|
|
265
|
+
problems.push({
|
|
266
|
+
code: 'ALREADY_RUNNING',
|
|
267
|
+
message: `The app already runs (pid ${running.pid}); verify does not stop it`,
|
|
268
|
+
fix: 'npx dokebi stop',
|
|
269
|
+
});
|
|
270
|
+
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
|
+
});
|
|
276
|
+
for (const db of project.dbUrls)
|
|
277
|
+
if (db.missing)
|
|
278
|
+
problems.push({
|
|
279
|
+
code: 'DB_URL_MISSING',
|
|
280
|
+
message: `options.dokebi-db.connections.${db.name}.options.url is not set`,
|
|
281
|
+
fix: 'npx dokebi dev-infra (Docker) or write the URL in dokebi.yaml',
|
|
282
|
+
});
|
|
283
|
+
return problems.length
|
|
284
|
+
? {
|
|
285
|
+
ok: false,
|
|
286
|
+
code: problems[0]?.code,
|
|
287
|
+
message: problems.map((item) => item.message).join('; '),
|
|
288
|
+
details: { problems },
|
|
289
|
+
}
|
|
290
|
+
: { ok: true };
|
|
291
|
+
});
|
|
292
|
+
let started;
|
|
293
|
+
// Only the instance started here echoes this token (x-dokebi-verify, @dokebi/web).
|
|
294
|
+
const token = randomBytes(16).toString('hex');
|
|
295
|
+
const reason = !doctor.ok ? 'doctor failed' : !preflight.ok ? 'preflight failed' : undefined;
|
|
296
|
+
await run('start', reason
|
|
297
|
+
? skip(reason)
|
|
298
|
+
: async () => {
|
|
299
|
+
started = await server.start(root, {
|
|
300
|
+
waitMs: options.waitMs ?? 30_000,
|
|
301
|
+
env: { [VERIFY_TOKEN_ENV]: token },
|
|
302
|
+
});
|
|
303
|
+
return {
|
|
304
|
+
ok: started.ready,
|
|
305
|
+
...(started.ready ? {} : { code: 'NOT_READY', message: started.warnings.join('; ') }),
|
|
306
|
+
details: {
|
|
307
|
+
pid: started.pid,
|
|
308
|
+
urls: started.urls,
|
|
309
|
+
listen: started.listen,
|
|
310
|
+
log: started.log,
|
|
311
|
+
},
|
|
312
|
+
};
|
|
313
|
+
});
|
|
314
|
+
const host = ['0.0.0.0', '::', '[::]'].includes(project.web.host)
|
|
315
|
+
? '127.0.0.1'
|
|
316
|
+
: project.web.host;
|
|
317
|
+
const listen = started?.listen.length
|
|
318
|
+
? started.listen
|
|
319
|
+
: project.web.installed && project.web.port > 0
|
|
320
|
+
? [`http://${host.includes(':') ? `[${host}]` : host}:${project.web.port}`]
|
|
321
|
+
: [];
|
|
322
|
+
try {
|
|
323
|
+
await run('http', !project.web.installed
|
|
324
|
+
? skip('no web plugin')
|
|
325
|
+
: !started?.ready
|
|
326
|
+
? // Nothing of ours answers: whatever is on the port proves nothing (AI-M02).
|
|
327
|
+
async () => ({
|
|
328
|
+
ok: false,
|
|
329
|
+
skipped: 'the app did not start',
|
|
330
|
+
code: 'NOT_STARTED',
|
|
331
|
+
message: 'No HTTP check ran: the app verify starts did not start (see the start check)',
|
|
332
|
+
})
|
|
333
|
+
: async () => {
|
|
334
|
+
// Connect to the listening address; the Host header of each app domain picks the
|
|
335
|
+
// app (the readiness route answers every host).
|
|
336
|
+
const base = listen.find((url) => url.includes('127.0.0.1')) ?? listen[0];
|
|
337
|
+
if (!base)
|
|
338
|
+
return {
|
|
339
|
+
ok: false,
|
|
340
|
+
code: 'URL_UNKNOWN',
|
|
341
|
+
message: 'No address: the web listening line is missing and options.dokebi-web.port is 0',
|
|
342
|
+
};
|
|
343
|
+
return httpChecks(base, { ready: project.web.ready, domains: project.domains }, token);
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
finally {
|
|
347
|
+
await run('stop', started
|
|
348
|
+
? async () => {
|
|
349
|
+
// The process this run started, by its pids: no `ps` needed (server.ts, D-0065 §11).
|
|
350
|
+
const stopped = await server.stopStarted(root, started, 15_000, true);
|
|
351
|
+
return {
|
|
352
|
+
ok: stopped.stopped && !stopped.forced,
|
|
353
|
+
...(stopped.forced
|
|
354
|
+
? { code: 'STOP_FORCED', message: 'The app did not drain in 15 s and was killed' }
|
|
355
|
+
: stopped.stale
|
|
356
|
+
? {
|
|
357
|
+
code: 'APP_EXITED',
|
|
358
|
+
message: 'The app exited by itself before verify stopped it: npx dokebi logs',
|
|
359
|
+
}
|
|
360
|
+
: {}),
|
|
361
|
+
details: {
|
|
362
|
+
ms: stopped.ms ?? null,
|
|
363
|
+
forced: stopped.forced ?? false,
|
|
364
|
+
...(stopped.warnings ? { warnings: stopped.warnings } : {}),
|
|
365
|
+
},
|
|
366
|
+
};
|
|
367
|
+
}
|
|
368
|
+
: skip('nothing was started'));
|
|
369
|
+
}
|
|
370
|
+
const ok = checks.every((check) => check.ok);
|
|
371
|
+
const tellUser = ok ? tellUserLines(await readAppliedRecipes(root)) : [];
|
|
372
|
+
const shown = started ? listen : [];
|
|
373
|
+
return {
|
|
374
|
+
ok,
|
|
375
|
+
checks,
|
|
376
|
+
urls: shown.length ? appUrls(shown, await server.appHostsOrNone(root)) : [],
|
|
377
|
+
listen: shown,
|
|
378
|
+
...(tellUser.length ? { tellUser } : {}),
|
|
379
|
+
};
|
|
380
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { ChildProcess } from 'node:child_process';
|
|
2
|
+
/** The host script of the installed template-inertia, or undefined when it has none (< 0.4.0). */
|
|
3
|
+
export declare function viteHostScript(root: string): string | undefined;
|
|
4
|
+
export interface ViteHostAddress {
|
|
5
|
+
readonly url: string;
|
|
6
|
+
readonly key: string;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* The host process: `ensure` starts it when it is not running and resolves with its address, or
|
|
10
|
+
* with undefined when it failed (said on the terminal; the next change tries again).
|
|
11
|
+
*/
|
|
12
|
+
export declare class ViteHostProcess {
|
|
13
|
+
#private;
|
|
14
|
+
constructor(spawn: () => ChildProcess, say: (line: string) => void, readyMs?: number);
|
|
15
|
+
/** A host process is starting or running. */
|
|
16
|
+
get running(): boolean;
|
|
17
|
+
ensure(): Promise<ViteHostAddress | undefined>;
|
|
18
|
+
/** Stops the host (SIGTERM, then SIGKILL after `limitMs`); `force` kills at once. */
|
|
19
|
+
stop(limitMs?: number, force?: boolean): Promise<void>;
|
|
20
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { createRequire } from 'node:module';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { debug, reportError } from './diagnostics.js';
|
|
4
|
+
/** The host script of the installed template-inertia, or undefined when it has none (< 0.4.0). */
|
|
5
|
+
export function viteHostScript(root) {
|
|
6
|
+
try {
|
|
7
|
+
return createRequire(join(root, 'package.json')).resolve('@dokebi/template-inertia/vite-host');
|
|
8
|
+
}
|
|
9
|
+
catch (error) {
|
|
10
|
+
const code = error.code;
|
|
11
|
+
if (code === 'MODULE_NOT_FOUND' || code === 'ERR_PACKAGE_PATH_NOT_EXPORTED') {
|
|
12
|
+
debug('template-inertia has no Vite dev host; Vite runs inside the app', { code });
|
|
13
|
+
return undefined;
|
|
14
|
+
}
|
|
15
|
+
reportError('DEV_VITE_HOST_LOOKUP_FAILED', 'Unable to find the Vite dev host script', error);
|
|
16
|
+
return undefined;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* The host process: `ensure` starts it when it is not running and resolves with its address, or
|
|
21
|
+
* with undefined when it failed (said on the terminal; the next change tries again).
|
|
22
|
+
*/
|
|
23
|
+
export class ViteHostProcess {
|
|
24
|
+
#spawn;
|
|
25
|
+
#say;
|
|
26
|
+
#readyMs;
|
|
27
|
+
#child;
|
|
28
|
+
#address;
|
|
29
|
+
#stopping = false;
|
|
30
|
+
constructor(spawn, say, readyMs = 60_000) {
|
|
31
|
+
this.#spawn = spawn;
|
|
32
|
+
this.#say = say;
|
|
33
|
+
this.#readyMs = readyMs;
|
|
34
|
+
}
|
|
35
|
+
/** A host process is starting or running. */
|
|
36
|
+
get running() {
|
|
37
|
+
return this.#child !== undefined;
|
|
38
|
+
}
|
|
39
|
+
ensure() {
|
|
40
|
+
this.#address ??= this.#start();
|
|
41
|
+
return this.#address;
|
|
42
|
+
}
|
|
43
|
+
#start() {
|
|
44
|
+
const begin = performance.now();
|
|
45
|
+
const child = this.#spawn();
|
|
46
|
+
this.#child = child;
|
|
47
|
+
debug('Vite dev host spawned', { pid: child.pid });
|
|
48
|
+
return new Promise((resolve) => {
|
|
49
|
+
let settled = false;
|
|
50
|
+
let ready = false;
|
|
51
|
+
const settle = (address) => {
|
|
52
|
+
if (settled)
|
|
53
|
+
return;
|
|
54
|
+
settled = true;
|
|
55
|
+
ready = address !== undefined;
|
|
56
|
+
clearTimeout(timer);
|
|
57
|
+
child.off('message', onMessage);
|
|
58
|
+
resolve(address);
|
|
59
|
+
};
|
|
60
|
+
const timer = setTimeout(() => {
|
|
61
|
+
this.#say(`vite dev server did not start in ${this.#readyMs / 1000} s; stopping it`);
|
|
62
|
+
child.kill('SIGKILL');
|
|
63
|
+
}, this.#readyMs);
|
|
64
|
+
const onMessage = (message) => {
|
|
65
|
+
if (message?.type !== 'dokebi-vite-host')
|
|
66
|
+
return;
|
|
67
|
+
if (message.state === 'ready' &&
|
|
68
|
+
typeof message.url === 'string' &&
|
|
69
|
+
typeof message.key === 'string') {
|
|
70
|
+
debug('Vite dev host ready', {
|
|
71
|
+
pid: child.pid,
|
|
72
|
+
url: message.url,
|
|
73
|
+
ms: Math.round(performance.now() - begin),
|
|
74
|
+
});
|
|
75
|
+
this.#say(`vite dev server: ${message.url} (one for every worker)`);
|
|
76
|
+
settle({ url: message.url, key: message.key });
|
|
77
|
+
}
|
|
78
|
+
else if (message.state === 'failed') {
|
|
79
|
+
this.#say(`vite dev server failed to start: ${String(message.message ?? '')}; waiting for a change to retry`);
|
|
80
|
+
settle(undefined);
|
|
81
|
+
// It exits by itself; this only makes sure it does not linger.
|
|
82
|
+
child.kill('SIGTERM');
|
|
83
|
+
}
|
|
84
|
+
};
|
|
85
|
+
child.on('message', onMessage);
|
|
86
|
+
child.once('exit', (code, signal) => {
|
|
87
|
+
if (this.#child === child) {
|
|
88
|
+
this.#child = undefined;
|
|
89
|
+
this.#address = undefined;
|
|
90
|
+
}
|
|
91
|
+
if (!settled) {
|
|
92
|
+
this.#say(`vite dev server exited (${signal ?? code}); waiting for a change to retry`);
|
|
93
|
+
settle(undefined);
|
|
94
|
+
}
|
|
95
|
+
else if (ready && !this.#stopping)
|
|
96
|
+
this.#say(`vite dev server exited (${signal ?? code}); it starts again with the next change`);
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
/** Stops the host (SIGTERM, then SIGKILL after `limitMs`); `force` kills at once. */
|
|
101
|
+
async stop(limitMs = 5000, force = false) {
|
|
102
|
+
this.#stopping = true;
|
|
103
|
+
const child = this.#child;
|
|
104
|
+
if (!child || child.exitCode !== null || child.signalCode !== null)
|
|
105
|
+
return;
|
|
106
|
+
const exited = new Promise((resolve) => child.once('exit', resolve));
|
|
107
|
+
child.kill(force ? 'SIGKILL' : 'SIGTERM');
|
|
108
|
+
const timer = setTimeout(() => child.kill('SIGKILL'), limitMs);
|
|
109
|
+
await exited;
|
|
110
|
+
clearTimeout(timer);
|
|
111
|
+
debug('Vite dev host stopped', {
|
|
112
|
+
pid: child.pid,
|
|
113
|
+
code: child.exitCode,
|
|
114
|
+
signal: child.signalCode,
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,14 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dokebi/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.1",
|
|
4
|
+
"description": "Project scaffolding, plugin catalog, recipes, install and verification CLI for Dokebi.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"dokebi",
|
|
7
|
+
"cli",
|
|
8
|
+
"scaffold",
|
|
9
|
+
"plugins",
|
|
10
|
+
"catalog"
|
|
11
|
+
],
|
|
4
12
|
"private": false,
|
|
5
13
|
"publishConfig": {
|
|
6
14
|
"access": "public",
|
|
@@ -15,17 +23,18 @@
|
|
|
15
23
|
},
|
|
16
24
|
"files": [
|
|
17
25
|
"dist",
|
|
18
|
-
"AI.md"
|
|
26
|
+
"AI.md",
|
|
27
|
+
"catalog.json"
|
|
19
28
|
],
|
|
20
29
|
"scripts": {
|
|
21
30
|
"build": "tsc -p tsconfig.json",
|
|
22
31
|
"typecheck": "tsc -p tsconfig.test.json --noEmit",
|
|
23
|
-
"test": "tsc -p tsconfig.test.json && node --test \".tmp/tests/tests/*.test.js\""
|
|
32
|
+
"test": "node -e \"require('node:fs').rmSync('.tmp/tests',{recursive:true,force:true})\" && tsc -p tsconfig.test.json && node --test \".tmp/tests/tests/*.test.js\""
|
|
24
33
|
},
|
|
25
34
|
"dependencies": {
|
|
26
35
|
"semver": "7.8.5",
|
|
27
|
-
"@dokebi/core": "^0.
|
|
28
|
-
"@dokebi/config": "^0.
|
|
36
|
+
"@dokebi/core": "^0.4.0",
|
|
37
|
+
"@dokebi/config": "^0.4.0",
|
|
29
38
|
"yaml": "2.9.1"
|
|
30
39
|
},
|
|
31
40
|
"devDependencies": {
|
|
@@ -37,5 +46,20 @@
|
|
|
37
46
|
"directory": "packages/cli"
|
|
38
47
|
},
|
|
39
48
|
"license": "MIT",
|
|
40
|
-
"
|
|
49
|
+
"dokebiCatalog": {
|
|
50
|
+
"kind": "cli",
|
|
51
|
+
"summary": "Commands to create a project, browse the plugin catalog, run recipes, install plugins and verify (dokebi)",
|
|
52
|
+
"maturity": "preview",
|
|
53
|
+
"provides": [
|
|
54
|
+
"cli.project"
|
|
55
|
+
],
|
|
56
|
+
"doesNot": [
|
|
57
|
+
"It is not the npm package named dokebi (never npx -y dokebi)",
|
|
58
|
+
"It does not write app code for you"
|
|
59
|
+
],
|
|
60
|
+
"recipes": [
|
|
61
|
+
"api",
|
|
62
|
+
"blog"
|
|
63
|
+
]
|
|
64
|
+
}
|
|
41
65
|
}
|