@dokebi/cli 0.2.5 → 0.4.0
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 +2199 -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 +141 -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 +793 -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/configuration.js
CHANGED
|
@@ -39,7 +39,8 @@ export function renderConfig(document) {
|
|
|
39
39
|
}
|
|
40
40
|
return document.toString();
|
|
41
41
|
}
|
|
42
|
-
|
|
42
|
+
/** Never committed: real settings, the legacy backup and the generated secrets in .env (D-0073). */
|
|
43
|
+
export const CONFIG_IGNORES = ['dokebi.yaml', 'dokebi.json', '.dokebi/dokebi.json.backup', '.env'];
|
|
43
44
|
export function parseProjectConfig(source) {
|
|
44
45
|
try {
|
|
45
46
|
return readConfig(parseConfig(source));
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import type { CatalogVariant } from './catalog.js';
|
|
2
|
+
export declare const COMPOSE_FILE = "compose.yaml";
|
|
3
|
+
export { ENV_FILE } from './env-file.js';
|
|
4
|
+
export declare const PASSWORD_VARIABLE = "DOKEBI_DB_PASSWORD";
|
|
5
|
+
export declare const DB_SERVICE = "db";
|
|
6
|
+
/** A connection URL that still has to be written (url mode) starts with this. */
|
|
7
|
+
export declare const URL_PLACEHOLDER_PREFIX = "<";
|
|
8
|
+
export type DockerSettings = NonNullable<NonNullable<CatalogVariant['devInfra']>['docker']>;
|
|
9
|
+
/** URL-safe random password (no characters that need escaping in a URL or compose file). */
|
|
10
|
+
export declare function generatePassword(): string;
|
|
11
|
+
/** The value of one variable in a .env text (KEY=value lines), or undefined. */
|
|
12
|
+
export declare function envValue(text: string, name: string): string | undefined;
|
|
13
|
+
/** Appends `name=value` to a .env text. */
|
|
14
|
+
export declare function withEnv(text: string, name: string, value: string, comment: string): string;
|
|
15
|
+
/**
|
|
16
|
+
* The npm and Compose project name when the folder name leaves nothing usable. It names the
|
|
17
|
+
* user's project, not a plugin (it predates D-0070 and is kept so existing projects keep theirs).
|
|
18
|
+
*/
|
|
19
|
+
export declare const FALLBACK_PROJECT_NAME = "dokebi-app";
|
|
20
|
+
/** Compose project names: lower-case letters, digits, dashes and underscores. */
|
|
21
|
+
export declare function composeProject(name: string): string;
|
|
22
|
+
/** The compose.yaml text of the development database (the developer owns it afterwards). */
|
|
23
|
+
export declare function composeText(project: string, variant: string, docker: DockerSettings, port: number): string;
|
|
24
|
+
/** The connection URL from the variant template. */
|
|
25
|
+
export declare function connectionUrl(docker: DockerSettings, password: string, port: number): string;
|
|
26
|
+
/** The same URL with the password hidden, for results and logs. */
|
|
27
|
+
export declare function redactUrl(url: string): string;
|
|
28
|
+
/**
|
|
29
|
+
* Whether a TCP port on 127.0.0.1 is taken: something accepts connections, or this process
|
|
30
|
+
* cannot listen there (BSD lets a specific address bind next to a wildcard listener, so both).
|
|
31
|
+
*/
|
|
32
|
+
export declare function portInUse(port: number): Promise<boolean>;
|
|
33
|
+
/** The first free port after `port` (up to 100 tries), to name in PORT_IN_USE; never used silently. */
|
|
34
|
+
export declare function freePortAfter(port: number): Promise<number | undefined>;
|
|
35
|
+
/**
|
|
36
|
+
* A taken port is the user's call (AI-M03): the question to ask and what to do now. `delegated`:
|
|
37
|
+
* the user left the choices to the agent (--defaults / --answers-from defaults), so the agent may
|
|
38
|
+
* take the proposed port and must tell the user; otherwise it asks and ends its turn.
|
|
39
|
+
*/
|
|
40
|
+
export declare function portQuestion(input: {
|
|
41
|
+
readonly what: 'Web' | 'Database';
|
|
42
|
+
readonly port: number;
|
|
43
|
+
readonly alternative: number | undefined;
|
|
44
|
+
readonly flag: '--port' | '--db-port';
|
|
45
|
+
readonly delegated: boolean;
|
|
46
|
+
}): {
|
|
47
|
+
waitForUser: boolean;
|
|
48
|
+
question: string;
|
|
49
|
+
next: string;
|
|
50
|
+
};
|
|
51
|
+
/**
|
|
52
|
+
* Whether an executable named `name` is in a PATH folder: a cheap check (no process, no daemon
|
|
53
|
+
* call) for the questions (AI-M03). It does not prove that the program runs.
|
|
54
|
+
*/
|
|
55
|
+
export declare function commandOnPath(name: string, path?: string): boolean;
|
|
56
|
+
/** `docker version` and `docker compose version` both answer: Docker can run the database. */
|
|
57
|
+
export declare function dockerAvailable(): Promise<{
|
|
58
|
+
ok: boolean;
|
|
59
|
+
error?: string;
|
|
60
|
+
}>;
|
|
61
|
+
/** Whether the compose service of this project runs (a later recipe run skips `up`). */
|
|
62
|
+
export declare function composeServiceRunning(root: string): Promise<boolean>;
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
// Development infrastructure (D-0065): compose.yaml for the database a project uses, the
|
|
2
|
+
// generated password in .env and the matching dokebi.yaml address. Docker data comes from the
|
|
3
|
+
// db package's `dokebiCatalog.variants[].devInfra.docker`.
|
|
4
|
+
import { execFile } from 'node:child_process';
|
|
5
|
+
import { randomBytes } from 'node:crypto';
|
|
6
|
+
import { statSync } from 'node:fs';
|
|
7
|
+
import { connect, createServer } from 'node:net';
|
|
8
|
+
import { delimiter, join } from 'node:path';
|
|
9
|
+
import { promisify } from 'node:util';
|
|
10
|
+
import { stringify } from 'yaml';
|
|
11
|
+
import { debug, describeError, isMissing } from './diagnostics.js';
|
|
12
|
+
const exec = promisify(execFile);
|
|
13
|
+
export const COMPOSE_FILE = 'compose.yaml';
|
|
14
|
+
export { ENV_FILE } from './env-file.js';
|
|
15
|
+
export const PASSWORD_VARIABLE = 'DOKEBI_DB_PASSWORD';
|
|
16
|
+
export const DB_SERVICE = 'db';
|
|
17
|
+
/** A connection URL that still has to be written (url mode) starts with this. */
|
|
18
|
+
export const URL_PLACEHOLDER_PREFIX = '<';
|
|
19
|
+
/** URL-safe random password (no characters that need escaping in a URL or compose file). */
|
|
20
|
+
export function generatePassword() {
|
|
21
|
+
return randomBytes(18).toString('base64url');
|
|
22
|
+
}
|
|
23
|
+
/** The value of one variable in a .env text (KEY=value lines), or undefined. */
|
|
24
|
+
export function envValue(text, name) {
|
|
25
|
+
for (const line of text.split(/\r?\n/)) {
|
|
26
|
+
const match = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/.exec(line);
|
|
27
|
+
if (match?.[1] === name)
|
|
28
|
+
return (match[2] ?? '').trim().replace(/^(['"])(.*)\1$/, '$2');
|
|
29
|
+
}
|
|
30
|
+
return undefined;
|
|
31
|
+
}
|
|
32
|
+
/** Appends `name=value` to a .env text. */
|
|
33
|
+
export function withEnv(text, name, value, comment) {
|
|
34
|
+
return `${text}${text && !text.endsWith('\n') ? '\n' : ''}# ${comment}\n${name}=${value}\n`;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* The npm and Compose project name when the folder name leaves nothing usable. It names the
|
|
38
|
+
* user's project, not a plugin (it predates D-0070 and is kept so existing projects keep theirs).
|
|
39
|
+
*/
|
|
40
|
+
export const FALLBACK_PROJECT_NAME = 'dokebi-app';
|
|
41
|
+
/** Compose project names: lower-case letters, digits, dashes and underscores. */
|
|
42
|
+
export function composeProject(name) {
|
|
43
|
+
const cleaned = name
|
|
44
|
+
.toLowerCase()
|
|
45
|
+
.replace(/^@[^/]+\//, '')
|
|
46
|
+
.replace(/[^a-z0-9_-]+/g, '-')
|
|
47
|
+
.replace(/^[^a-z0-9]+/, '');
|
|
48
|
+
return cleaned || FALLBACK_PROJECT_NAME;
|
|
49
|
+
}
|
|
50
|
+
/** Where the image keeps its data (a named volume is mounted there). */
|
|
51
|
+
function dataDirectory(image) {
|
|
52
|
+
if (image.startsWith('postgres'))
|
|
53
|
+
return '/var/lib/postgresql/data';
|
|
54
|
+
if (image.startsWith('mongo'))
|
|
55
|
+
return '/data/db';
|
|
56
|
+
return '/var/lib/mysql';
|
|
57
|
+
}
|
|
58
|
+
/** The compose.yaml text of the development database (the developer owns it afterwards). */
|
|
59
|
+
export function composeText(project, variant, docker, port) {
|
|
60
|
+
const interpolate = (value) => value.replaceAll(`\${${PASSWORD_VARIABLE}}`, `\${${PASSWORD_VARIABLE}:?set ${PASSWORD_VARIABLE} in .env (npx dokebi dev-infra writes it)}`);
|
|
61
|
+
const document = {
|
|
62
|
+
name: project,
|
|
63
|
+
services: {
|
|
64
|
+
[DB_SERVICE]: {
|
|
65
|
+
image: docker.image,
|
|
66
|
+
restart: 'unless-stopped',
|
|
67
|
+
environment: Object.fromEntries(Object.entries(docker.env).map(([key, value]) => [key, interpolate(value)])),
|
|
68
|
+
ports: [`127.0.0.1:${port}:${docker.port}`],
|
|
69
|
+
volumes: [`db-data:${dataDirectory(docker.image)}`],
|
|
70
|
+
healthcheck: {
|
|
71
|
+
test: docker.healthcheck.map(interpolate),
|
|
72
|
+
interval: '2s',
|
|
73
|
+
timeout: '5s',
|
|
74
|
+
retries: 60,
|
|
75
|
+
},
|
|
76
|
+
},
|
|
77
|
+
},
|
|
78
|
+
volumes: { 'db-data': {} },
|
|
79
|
+
};
|
|
80
|
+
return `# Development ${variant} database of this project, written by \`npx dokebi dev-infra\`.
|
|
81
|
+
# It is yours to change. The password is read from .env (not committed); dokebi.yaml holds the
|
|
82
|
+
# same address for the app. Start: docker compose up -d --wait · Stop: docker compose down
|
|
83
|
+
# (add -v to delete the data volume).
|
|
84
|
+
${stringify(document, { lineWidth: 0 })}`;
|
|
85
|
+
}
|
|
86
|
+
/** The connection URL from the variant template. */
|
|
87
|
+
export function connectionUrl(docker, password, port) {
|
|
88
|
+
return docker.url
|
|
89
|
+
.replace('{password}', encodeURIComponent(password))
|
|
90
|
+
.replace('{port}', String(port));
|
|
91
|
+
}
|
|
92
|
+
/** The same URL with the password hidden, for results and logs. */
|
|
93
|
+
export function redactUrl(url) {
|
|
94
|
+
return url.replace(/\/\/([^:/@]*):[^@/]*@/, '//$1:***@');
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Whether a TCP port on 127.0.0.1 is taken: something accepts connections, or this process
|
|
98
|
+
* cannot listen there (BSD lets a specific address bind next to a wildcard listener, so both).
|
|
99
|
+
*/
|
|
100
|
+
export async function portInUse(port) {
|
|
101
|
+
const accepts = await new Promise((resolve) => {
|
|
102
|
+
const socket = connect({ host: '127.0.0.1', port });
|
|
103
|
+
socket.setTimeout(1000);
|
|
104
|
+
socket.once('connect', () => {
|
|
105
|
+
socket.destroy();
|
|
106
|
+
resolve(true);
|
|
107
|
+
});
|
|
108
|
+
socket.once('timeout', () => {
|
|
109
|
+
socket.destroy();
|
|
110
|
+
resolve(false);
|
|
111
|
+
});
|
|
112
|
+
// Refused or unreachable is the answer "nothing accepts there", not a failure.
|
|
113
|
+
socket.once('error', (error) => {
|
|
114
|
+
debug('Port probe connect failed', { port, code: error.code });
|
|
115
|
+
resolve(false);
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
if (accepts)
|
|
119
|
+
return true;
|
|
120
|
+
return new Promise((resolve) => {
|
|
121
|
+
const server = createServer();
|
|
122
|
+
server.once('error', (error) => {
|
|
123
|
+
debug('Port probe could not listen', { port, code: error.code });
|
|
124
|
+
resolve(true);
|
|
125
|
+
});
|
|
126
|
+
server.listen(port, '127.0.0.1', () => server.close(() => resolve(false)));
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
/** The first free port after `port` (up to 100 tries), to name in PORT_IN_USE; never used silently. */
|
|
130
|
+
export async function freePortAfter(port) {
|
|
131
|
+
for (let candidate = port + 1; candidate <= Math.min(65535, port + 100); candidate++)
|
|
132
|
+
if (!(await portInUse(candidate)))
|
|
133
|
+
return candidate;
|
|
134
|
+
return undefined;
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* A taken port is the user's call (AI-M03): the question to ask and what to do now. `delegated`:
|
|
138
|
+
* the user left the choices to the agent (--defaults / --answers-from defaults), so the agent may
|
|
139
|
+
* take the proposed port and must tell the user; otherwise it asks and ends its turn.
|
|
140
|
+
*/
|
|
141
|
+
export function portQuestion(input) {
|
|
142
|
+
const { what, port, alternative, flag, delegated } = input;
|
|
143
|
+
const proposal = alternative ?? '<free port>';
|
|
144
|
+
const question = `${what} port ${port} is in use by another program. Use port ${proposal} instead (${flag} ${proposal}), or stop the program that uses ${port} and keep ${port}?`;
|
|
145
|
+
const rule = 'do not pick the port yourself unless the user left choices to you (--defaults / --answers-from defaults)';
|
|
146
|
+
return {
|
|
147
|
+
waitForUser: !delegated,
|
|
148
|
+
question,
|
|
149
|
+
next: delegated
|
|
150
|
+
? `The user left the choices to you (--defaults), so you may run the same command again with ${flag} ${proposal}; tell the user the port you chose and why (${port} was in use). Nothing was changed. Otherwise ${rule}.`
|
|
151
|
+
: `Ask the user this question and end your turn; ${rule}. Nothing was changed. After the answer, run the same command again with ${flag} <the chosen port>, or as it was once the user stopped the other program.`,
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* Whether an executable named `name` is in a PATH folder: a cheap check (no process, no daemon
|
|
156
|
+
* call) for the questions (AI-M03). It does not prove that the program runs.
|
|
157
|
+
*/
|
|
158
|
+
export function commandOnPath(name, path = process.env.PATH ?? '') {
|
|
159
|
+
const names = process.platform === 'win32'
|
|
160
|
+
? (process.env.PATHEXT ?? '.EXE;.CMD;.BAT').split(';').map((ext) => `${name}${ext}`)
|
|
161
|
+
: [name];
|
|
162
|
+
for (const folder of path.split(delimiter)) {
|
|
163
|
+
if (!folder)
|
|
164
|
+
continue;
|
|
165
|
+
for (const file of names) {
|
|
166
|
+
let found = false;
|
|
167
|
+
try {
|
|
168
|
+
found = statSync(join(folder, file)).isFile();
|
|
169
|
+
}
|
|
170
|
+
catch (error) {
|
|
171
|
+
// Not there (ENOENT, or ENOTDIR for a PATH entry that is a file) is the usual answer; a
|
|
172
|
+
// folder this user may not read (EACCES) cannot hold a command they can run either.
|
|
173
|
+
if (!isMissing(error))
|
|
174
|
+
debug('A PATH folder could not be read while looking for a command', {
|
|
175
|
+
name,
|
|
176
|
+
folder,
|
|
177
|
+
error: describeError(error),
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
if (found) {
|
|
181
|
+
debug('Command found on PATH', { name, file: join(folder, file) });
|
|
182
|
+
return true;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
debug('Command not found on PATH', { name });
|
|
187
|
+
return false;
|
|
188
|
+
}
|
|
189
|
+
/** `docker version` and `docker compose version` both answer: Docker can run the database. */
|
|
190
|
+
export async function dockerAvailable() {
|
|
191
|
+
try {
|
|
192
|
+
await exec('docker', ['version', '--format', '{{.Server.Version}}'], { timeout: 15_000 });
|
|
193
|
+
await exec('docker', ['compose', 'version'], { timeout: 15_000 });
|
|
194
|
+
return { ok: true };
|
|
195
|
+
}
|
|
196
|
+
catch (error) {
|
|
197
|
+
debug('Docker is not available', { error: describeError(error) });
|
|
198
|
+
return {
|
|
199
|
+
ok: false,
|
|
200
|
+
error: error.stderr?.trim() || error.message,
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
/** Whether the compose service of this project runs (a later recipe run skips `up`). */
|
|
205
|
+
export async function composeServiceRunning(root) {
|
|
206
|
+
try {
|
|
207
|
+
const { stdout } = await exec('docker', ['compose', 'ps', '--status', 'running', '--services'], { cwd: root, timeout: 15_000 });
|
|
208
|
+
return stdout.split('\n').includes(DB_SERVICE);
|
|
209
|
+
}
|
|
210
|
+
catch (error) {
|
|
211
|
+
// "Not running" makes the recipe run `docker compose up`, which reports the real failure.
|
|
212
|
+
debug('docker compose ps failed', { error: describeError(error) });
|
|
213
|
+
return false;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/** The file `dokebi dev-infra` and plugin installs write and the server commands read. */
|
|
2
|
+
export declare const ENV_FILE = ".env";
|
|
3
|
+
/**
|
|
4
|
+
* Checks that every line of a .env text is blank, a comment or `KEY=value` (a quoted value may
|
|
5
|
+
* span lines and must be closed). The values themselves are left to `util.parseEnv`; the error
|
|
6
|
+
* names the line only, never its content.
|
|
7
|
+
*/
|
|
8
|
+
export declare function checkEnvText(text: string): void;
|
|
9
|
+
/**
|
|
10
|
+
* The variables of `<root>/.env` (empty when there is none). Throws ENV_FILE_INVALID when the
|
|
11
|
+
* file cannot be read or a line is not `KEY=value`.
|
|
12
|
+
*/
|
|
13
|
+
export declare function readEnvFile(root: string): Record<string, string>;
|
|
14
|
+
/**
|
|
15
|
+
* The environment of an app the CLI runs: the project's .env, then the CLI's own environment on
|
|
16
|
+
* top (it wins), then NODE_ENV (`mode` unless one of them set it) and `extra` (the CLI's own
|
|
17
|
+
* variables such as DOKEBI_DEV or the verify and instance tokens).
|
|
18
|
+
*/
|
|
19
|
+
export declare function appEnvironment(root: string, mode: string, extra?: Readonly<Record<string, string>>, fileValues?: Readonly<Record<string, string>>): NodeJS.ProcessEnv;
|
|
20
|
+
/** Whether a variable is set in the environment or in the project's .env (values not returned). */
|
|
21
|
+
export declare function envHas(root: string, name: string): {
|
|
22
|
+
environment: boolean;
|
|
23
|
+
file: boolean;
|
|
24
|
+
};
|
package/dist/env-file.js
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// The project's `.env` for the apps the CLI runs (D-0073): `dokebi dev`, `start` (and
|
|
2
|
+
// `start --foreground`) and `verify` pass its variables to the app. Variables already set in the
|
|
3
|
+
// environment win, so a shell, a CI job or a production secret manager always decides. Values
|
|
4
|
+
// are never printed or logged; only the file and the variable names are (debug). A file that
|
|
5
|
+
// cannot be read or has a line that is not `KEY=value` is a coded error (ENV_FILE_INVALID), not
|
|
6
|
+
// silently skipped: an app that starts without the secret it expects is harder to explain.
|
|
7
|
+
import { readFileSync } from 'node:fs';
|
|
8
|
+
import { join } from 'node:path';
|
|
9
|
+
import { parseEnv } from 'node:util';
|
|
10
|
+
import { debug, isMissing } from './diagnostics.js';
|
|
11
|
+
import { CliError } from './errors.js';
|
|
12
|
+
/** The file `dokebi dev-infra` and plugin installs write and the server commands read. */
|
|
13
|
+
export const ENV_FILE = '.env';
|
|
14
|
+
const ASSIGNMENT = /^\s*(?:export\s+)?[A-Za-z_][A-Za-z0-9_.-]*\s*=(.*)$/;
|
|
15
|
+
function invalid(line, reason) {
|
|
16
|
+
return new CliError('ENV_FILE_INVALID', `${ENV_FILE} line ${line}: ${reason}`, {
|
|
17
|
+
details: {
|
|
18
|
+
file: ENV_FILE,
|
|
19
|
+
line,
|
|
20
|
+
fix: `edit ${ENV_FILE}: one KEY=value per line (# starts a comment; quote values with spaces or several lines), or remove the line`,
|
|
21
|
+
},
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Checks that every line of a .env text is blank, a comment or `KEY=value` (a quoted value may
|
|
26
|
+
* span lines and must be closed). The values themselves are left to `util.parseEnv`; the error
|
|
27
|
+
* names the line only, never its content.
|
|
28
|
+
*/
|
|
29
|
+
export function checkEnvText(text) {
|
|
30
|
+
const lines = text.split(/\r?\n/);
|
|
31
|
+
for (let index = 0; index < lines.length; index++) {
|
|
32
|
+
const line = lines[index] ?? '';
|
|
33
|
+
const trimmed = line.trim();
|
|
34
|
+
if (!trimmed || trimmed.startsWith('#'))
|
|
35
|
+
continue;
|
|
36
|
+
const match = ASSIGNMENT.exec(line);
|
|
37
|
+
if (!match)
|
|
38
|
+
throw invalid(index + 1, 'not a KEY=value line');
|
|
39
|
+
const value = (match[1] ?? '').trimStart();
|
|
40
|
+
const quote = value[0];
|
|
41
|
+
if (quote !== '"' && quote !== "'" && quote !== '`')
|
|
42
|
+
continue;
|
|
43
|
+
if (value.indexOf(quote, 1) !== -1)
|
|
44
|
+
continue;
|
|
45
|
+
// A quoted value that goes on: it ends on the first later line with the same quote.
|
|
46
|
+
const opened = index + 1;
|
|
47
|
+
do {
|
|
48
|
+
index++;
|
|
49
|
+
if (index >= lines.length)
|
|
50
|
+
throw invalid(opened, `the ${quote} quote is never closed`);
|
|
51
|
+
} while (!(lines[index] ?? '').includes(quote));
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* The variables of `<root>/.env` (empty when there is none). Throws ENV_FILE_INVALID when the
|
|
56
|
+
* file cannot be read or a line is not `KEY=value`.
|
|
57
|
+
*/
|
|
58
|
+
export function readEnvFile(root) {
|
|
59
|
+
const file = join(root, ENV_FILE);
|
|
60
|
+
let text;
|
|
61
|
+
try {
|
|
62
|
+
text = readFileSync(file, 'utf8');
|
|
63
|
+
}
|
|
64
|
+
catch (error) {
|
|
65
|
+
if (isMissing(error))
|
|
66
|
+
return {};
|
|
67
|
+
throw new CliError('ENV_FILE_INVALID', `Unable to read ${ENV_FILE}`, {
|
|
68
|
+
cause: error,
|
|
69
|
+
details: {
|
|
70
|
+
file: ENV_FILE,
|
|
71
|
+
fix: `make ${ENV_FILE} a readable file (or remove it)`,
|
|
72
|
+
},
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
checkEnvText(text);
|
|
76
|
+
const values = parseEnv(text);
|
|
77
|
+
debug('Read the project .env for the app', { file, variables: Object.keys(values) });
|
|
78
|
+
return values;
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* The environment of an app the CLI runs: the project's .env, then the CLI's own environment on
|
|
82
|
+
* top (it wins), then NODE_ENV (`mode` unless one of them set it) and `extra` (the CLI's own
|
|
83
|
+
* variables such as DOKEBI_DEV or the verify and instance tokens).
|
|
84
|
+
*/
|
|
85
|
+
export function appEnvironment(root, mode, extra = {}, fileValues = readEnvFile(root)) {
|
|
86
|
+
const overridden = Object.keys(fileValues).filter((key) => process.env[key] !== undefined);
|
|
87
|
+
if (overridden.length)
|
|
88
|
+
debug('Variables of .env already set in the environment; the environment wins', {
|
|
89
|
+
variables: overridden,
|
|
90
|
+
});
|
|
91
|
+
const merged = { ...fileValues, ...process.env };
|
|
92
|
+
return { ...merged, NODE_ENV: merged.NODE_ENV ?? mode, ...extra };
|
|
93
|
+
}
|
|
94
|
+
/** Whether a variable is set in the environment or in the project's .env (values not returned). */
|
|
95
|
+
export function envHas(root, name) {
|
|
96
|
+
return {
|
|
97
|
+
environment: process.env[name] !== undefined,
|
|
98
|
+
file: Object.hasOwn(readEnvFile(root), name),
|
|
99
|
+
};
|
|
100
|
+
}
|
package/dist/generator.d.ts
CHANGED
|
@@ -1,5 +1,19 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { type PluginManifest } from './manifest.js';
|
|
2
2
|
import type { Installation } from './project-state.js';
|
|
3
|
+
/**
|
|
4
|
+
* src/dokebi.generated.ts: imports every installed plugin and registers it in dependency order.
|
|
5
|
+
*
|
|
6
|
+
* Options are not checked here. Each plugin's factory parses its `options.<id>` section with its
|
|
7
|
+
* own declaration (D-0063 §3, `defineOptions`) when `registerPlugins` calls it, so an invalid
|
|
8
|
+
* dokebi.yaml still fails at startup — with `INVALID_PLUGIN_OPTIONS`, the plugin id, the option
|
|
9
|
+
* paths and a fix — without loading a JSON Schema validator (D-0070 §4: compiling the schemas
|
|
10
|
+
* with Ajv was about 40% of an 11-plugin start). `dokebi doctor`, `add` and `config set` still
|
|
11
|
+
* check the options.schema.json files offline.
|
|
12
|
+
*
|
|
13
|
+
* The CLI-managed units reach the plugin whose manifest declares their role (D-0070
|
|
14
|
+
* `dokebi.hosts`): `apps` (`dokebi app add`, src/apps/<name>/app.ts) and `modules`
|
|
15
|
+
* (`dokebi module add`, src/shared/modules/<name>/module.ts).
|
|
16
|
+
*/
|
|
3
17
|
export declare function generateRegistration(plugins: PluginManifest[], installations?: Readonly<Record<string, Installation>>, apps?: readonly string[], modules?: readonly string[]): string;
|
|
4
18
|
export declare const LEGACY_MAIN_SOURCE = "import { Application } from '@dokebi/core';\nimport { registerPlugins } from './dokebi.generated.ts';\n\nconst app = new Application();\nregisterPlugins(app);\nawait app.start();\ntry {\n app.logger.log('info', 'Dokebi application started');\n // Run local work here. Long-running apps own their signal and shutdown policy.\n} finally {\n await app.stop();\n}\n";
|
|
5
19
|
/** The 0.2.0 YAML entry point before it became long-running; migrate still recognizes it. */
|
|
@@ -10,4 +24,13 @@ export declare const MAIN_SOURCE = "import { Application, loadConfig } from '@do
|
|
|
10
24
|
* editor and `tsc` should reject syntax Node cannot run (erasableSyntaxOnly) and need explicit
|
|
11
25
|
* `.ts` imports and `import type` (allowImportingTsExtensions, verbatimModuleSyntax).
|
|
12
26
|
*/
|
|
27
|
+
/**
|
|
28
|
+
* Development tools `dokebi init` pins in a new project (stage 3 §0). `@dokebi/cli` is the CLI
|
|
29
|
+
* that created the project, so `npx dokebi` inside it runs the local bin and never npm's
|
|
30
|
+
* unrelated `dokebi` package. TypeScript and the Node types back `npm run typecheck`; their
|
|
31
|
+
* versions follow this repository's pins (a CLI test compares them with the root package.json).
|
|
32
|
+
*/
|
|
33
|
+
export declare const PROJECT_DEV_DEPENDENCIES: Readonly<Record<string, string>>;
|
|
34
|
+
/** `npm run typecheck`: the checks of the generated tsconfig.json (it already sets noEmit). */
|
|
35
|
+
export declare const TYPECHECK_SCRIPT = "tsc -p tsconfig.json --noEmit";
|
|
13
36
|
export declare const TSCONFIG_SOURCE: string;
|
package/dist/generator.js
CHANGED
|
@@ -1,31 +1,59 @@
|
|
|
1
|
+
import { hostOf } from './manifest.js';
|
|
2
|
+
import { CLI_VERSION } from './output.js';
|
|
1
3
|
/** Import specifier of a user entry file, relative to src/dokebi.generated.ts. */
|
|
2
4
|
function entrySpecifier(target) {
|
|
3
5
|
return './' + target.slice('src/'.length);
|
|
4
6
|
}
|
|
7
|
+
/**
|
|
8
|
+
* src/dokebi.generated.ts: imports every installed plugin and registers it in dependency order.
|
|
9
|
+
*
|
|
10
|
+
* Options are not checked here. Each plugin's factory parses its `options.<id>` section with its
|
|
11
|
+
* own declaration (D-0063 §3, `defineOptions`) when `registerPlugins` calls it, so an invalid
|
|
12
|
+
* dokebi.yaml still fails at startup — with `INVALID_PLUGIN_OPTIONS`, the plugin id, the option
|
|
13
|
+
* paths and a fix — without loading a JSON Schema validator (D-0070 §4: compiling the schemas
|
|
14
|
+
* with Ajv was about 40% of an 11-plugin start). `dokebi doctor`, `add` and `config set` still
|
|
15
|
+
* check the options.schema.json files offline.
|
|
16
|
+
*
|
|
17
|
+
* The CLI-managed units reach the plugin whose manifest declares their role (D-0070
|
|
18
|
+
* `dokebi.hosts`): `apps` (`dokebi app add`, src/apps/<name>/app.ts) and `modules`
|
|
19
|
+
* (`dokebi module add`, src/shared/modules/<name>/module.ts).
|
|
20
|
+
*/
|
|
5
21
|
export function generateRegistration(plugins, installations = {}, apps = [], modules = []) {
|
|
22
|
+
for (const [role, units] of [
|
|
23
|
+
['apps', apps],
|
|
24
|
+
['modules', modules],
|
|
25
|
+
])
|
|
26
|
+
if (units.length)
|
|
27
|
+
hostOf(plugins, role);
|
|
6
28
|
const imports = [];
|
|
7
29
|
const factories = [];
|
|
30
|
+
/** Plugins the CLI passes an entry (an entry file or the units they host). */
|
|
31
|
+
const withEntry = new Set();
|
|
8
32
|
plugins.forEach((plugin, index) => {
|
|
9
33
|
imports.push(`import { createPlugin as factory${index} } from ${JSON.stringify(plugin.packageName)};`);
|
|
10
34
|
const entry = installations[plugin.id]?.entry;
|
|
11
|
-
if (entry)
|
|
35
|
+
if (entry) {
|
|
12
36
|
imports.push(`import * as entry${index} from ${JSON.stringify(entrySpecifier(entry))};`);
|
|
37
|
+
withEntry.add(plugin.id);
|
|
38
|
+
}
|
|
13
39
|
let entryCode = entry ? `, entry: entry${index}` : '';
|
|
14
|
-
//
|
|
15
|
-
if (plugin.
|
|
40
|
+
// The apps host receives the apps recorded by `dokebi app add` (src/apps/<name>/app.ts).
|
|
41
|
+
if (plugin.hosts?.includes('apps')) {
|
|
16
42
|
apps.forEach((name, appIndex) => {
|
|
17
43
|
imports.push(`import app${appIndex} from ${JSON.stringify(`./apps/${name}/app.ts`)};`);
|
|
18
44
|
});
|
|
19
45
|
entryCode = `, entry: { apps: { ${apps.map((name, appIndex) => `${JSON.stringify(name)}: app${appIndex}`).join(', ')} } }`;
|
|
46
|
+
withEntry.add(plugin.id);
|
|
20
47
|
}
|
|
21
|
-
//
|
|
48
|
+
// The modules host receives the modules recorded by `dokebi module add` (D-0032), after the
|
|
22
49
|
// list of an older entry file (src/shared/modules/index.ts) when the project still has one.
|
|
23
|
-
if (plugin.
|
|
50
|
+
if (plugin.hosts?.includes('modules') && modules.length) {
|
|
24
51
|
modules.forEach((name, moduleIndex) => {
|
|
25
52
|
imports.push(`import module${moduleIndex} from ${JSON.stringify(`./shared/modules/${name}/module.ts`)};`);
|
|
26
53
|
});
|
|
27
54
|
const list = modules.map((_name, moduleIndex) => `module${moduleIndex}`);
|
|
28
55
|
entryCode = `, entry: { modules: [${[...(entry ? [`...entry${index}.modules`] : []), ...list].join(', ')}] }`;
|
|
56
|
+
withEntry.add(plugin.id);
|
|
29
57
|
}
|
|
30
58
|
factories.push(` factory${index}({ app: app.view, options: optionsOf(${JSON.stringify(plugin.id)})${entryCode} }),`);
|
|
31
59
|
});
|
|
@@ -35,22 +63,10 @@ export function generateRegistration(plugins, installations = {}, apps = [], mod
|
|
|
35
63
|
id: plugin.id,
|
|
36
64
|
version: plugin.version,
|
|
37
65
|
dependencies: plugin.dependencies.map((dependency) => dependency.name),
|
|
38
|
-
entry:
|
|
39
|
-
plugin.id === 'dokebi-app' ||
|
|
40
|
-
(plugin.id === 'dokebi-module' && modules.length > 0),
|
|
41
|
-
}));
|
|
42
|
-
// Schemas are found through module resolution, so hoisted installs (npm workspaces,
|
|
43
|
-
// monorepos) work the same as a project-local node_modules.
|
|
44
|
-
const schemas = plugins
|
|
45
|
-
.filter((plugin) => plugin.optionsSchema)
|
|
46
|
-
.map((plugin) => ({
|
|
47
|
-
id: plugin.id,
|
|
48
|
-
packageName: plugin.packageName,
|
|
49
|
-
file: plugin.optionsSchema,
|
|
66
|
+
entry: withEntry.has(plugin.id),
|
|
50
67
|
}));
|
|
51
68
|
return `// Generated by dokebi. Change dokebi.yaml options or plugin entry files instead of this file.
|
|
52
|
-
import {
|
|
53
|
-
import { type Application, ConfigValidator, loadSchema, type Plugin } from '@dokebi/core';
|
|
69
|
+
import type { Application, Plugin } from '@dokebi/core';
|
|
54
70
|
${imports.join('\n')}
|
|
55
71
|
|
|
56
72
|
const MANIFESTS: readonly {
|
|
@@ -59,39 +75,16 @@ const MANIFESTS: readonly {
|
|
|
59
75
|
dependencies: readonly string[];
|
|
60
76
|
entry: boolean;
|
|
61
77
|
}[] = ${JSON.stringify(manifests)};
|
|
62
|
-
const SCHEMAS: readonly { id: string; packageName: string; file: string }[] = ${JSON.stringify(schemas)};
|
|
63
|
-
|
|
64
|
-
/** A file inside an installed package, found from the package entry the app resolves. */
|
|
65
|
-
async function packageFile(packageName: string, file: string): Promise<URL> {
|
|
66
|
-
let directory = new URL('.', import.meta.resolve(packageName));
|
|
67
|
-
for (;;) {
|
|
68
|
-
try {
|
|
69
|
-
const manifest: unknown = JSON.parse(await readFile(new URL('package.json', directory), 'utf8'));
|
|
70
|
-
if (manifest !== null && typeof manifest === 'object' && 'name' in manifest && manifest.name === packageName)
|
|
71
|
-
return new URL(file, directory);
|
|
72
|
-
} catch (cause) {
|
|
73
|
-
if ((cause as { code?: unknown }).code !== 'ENOENT') throw cause;
|
|
74
|
-
}
|
|
75
|
-
const parent = new URL('..', directory);
|
|
76
|
-
if (parent.href === directory.href) throw new Error('Installed package not found: ' + packageName);
|
|
77
|
-
directory = parent;
|
|
78
|
-
}
|
|
79
|
-
}
|
|
80
78
|
|
|
79
|
+
/**
|
|
80
|
+
* Creates and registers the installed plugins. Each factory checks its options.<plugin> section
|
|
81
|
+
* of dokebi.yaml with the plugin's own declaration; an invalid value fails here with
|
|
82
|
+
* INVALID_PLUGIN_OPTIONS (the plugin, the option paths and a fix).
|
|
83
|
+
*/
|
|
81
84
|
export async function registerPlugins(app: Application): Promise<void> {
|
|
82
85
|
const options: Record<string, unknown> = app.config;
|
|
83
86
|
const optionsOf = (id: string): unknown =>
|
|
84
87
|
Object.hasOwn(options, id) && options[id] !== null ? options[id] : {};
|
|
85
|
-
const validator = new ConfigValidator();
|
|
86
|
-
for (const schema of SCHEMAS) {
|
|
87
|
-
let loaded: Record<string, unknown>;
|
|
88
|
-
try {
|
|
89
|
-
loaded = await loadSchema(await packageFile(schema.packageName, schema.file));
|
|
90
|
-
} catch (cause) {
|
|
91
|
-
throw new Error('Unable to load the options schema of plugin ' + schema.id, { cause });
|
|
92
|
-
}
|
|
93
|
-
await validator.validate(loaded, optionsOf(schema.id));
|
|
94
|
-
}
|
|
95
88
|
const plugins: Plugin<unknown>[] = [
|
|
96
89
|
${factories.join('\n')}
|
|
97
90
|
];
|
|
@@ -152,6 +145,19 @@ process.once('beforeExit', () => void stop('idle'));
|
|
|
152
145
|
* editor and `tsc` should reject syntax Node cannot run (erasableSyntaxOnly) and need explicit
|
|
153
146
|
* `.ts` imports and `import type` (allowImportingTsExtensions, verbatimModuleSyntax).
|
|
154
147
|
*/
|
|
148
|
+
/**
|
|
149
|
+
* Development tools `dokebi init` pins in a new project (stage 3 §0). `@dokebi/cli` is the CLI
|
|
150
|
+
* that created the project, so `npx dokebi` inside it runs the local bin and never npm's
|
|
151
|
+
* unrelated `dokebi` package. TypeScript and the Node types back `npm run typecheck`; their
|
|
152
|
+
* versions follow this repository's pins (a CLI test compares them with the root package.json).
|
|
153
|
+
*/
|
|
154
|
+
export const PROJECT_DEV_DEPENDENCIES = {
|
|
155
|
+
'@dokebi/cli': CLI_VERSION,
|
|
156
|
+
'@types/node': '24.19.2',
|
|
157
|
+
typescript: '7.0.2',
|
|
158
|
+
};
|
|
159
|
+
/** `npm run typecheck`: the checks of the generated tsconfig.json (it already sets noEmit). */
|
|
160
|
+
export const TYPECHECK_SCRIPT = 'tsc -p tsconfig.json --noEmit';
|
|
155
161
|
export const TSCONFIG_SOURCE = `${JSON.stringify({
|
|
156
162
|
compilerOptions: {
|
|
157
163
|
target: 'ES2023',
|
|
@@ -161,8 +167,12 @@ export const TSCONFIG_SOURCE = `${JSON.stringify({
|
|
|
161
167
|
noEmit: true,
|
|
162
168
|
allowImportingTsExtensions: true,
|
|
163
169
|
erasableSyntaxOnly: true,
|
|
170
|
+
// D-0064: overriding an extension hook needs `override`, so a misspelt hook is an error.
|
|
171
|
+
noImplicitOverride: true,
|
|
164
172
|
verbatimModuleSyntax: true,
|
|
165
173
|
skipLibCheck: true,
|
|
174
|
+
// TypeScript 6+ no longer loads every @types package; init installs @types/node.
|
|
175
|
+
types: ['node'],
|
|
166
176
|
},
|
|
167
177
|
include: ['src/**/*.ts'],
|
|
168
178
|
}, null, 2)}\n`;
|
package/dist/guide.d.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { Catalog } from './catalog.js';
|
|
2
|
+
/** One step of the AI flow: a command and why. */
|
|
3
|
+
export interface GuideStep {
|
|
4
|
+
readonly command: string;
|
|
5
|
+
readonly why: string;
|
|
6
|
+
}
|
|
7
|
+
export interface Guide {
|
|
8
|
+
readonly cliVersion: string;
|
|
9
|
+
/** The flow from a one-line request ("make me a blog") to a verified app. */
|
|
10
|
+
readonly steps: readonly GuideStep[];
|
|
11
|
+
/** Rules that hold during every step. */
|
|
12
|
+
readonly rules: readonly string[];
|
|
13
|
+
readonly recipes: readonly {
|
|
14
|
+
name: string;
|
|
15
|
+
summary: string;
|
|
16
|
+
questions: readonly string[];
|
|
17
|
+
}[];
|
|
18
|
+
readonly absent: readonly string[];
|
|
19
|
+
readonly commands: readonly {
|
|
20
|
+
usage: string;
|
|
21
|
+
summary: string;
|
|
22
|
+
}[];
|
|
23
|
+
readonly text: string;
|
|
24
|
+
}
|
|
25
|
+
/** The short pointer printed at the top of `dokebi --help`. */
|
|
26
|
+
export declare const HELP_POINTER = "AI and automation: read `dokebi guide` (--json) first. Ask the user every choice and end your turn until they answer; finish with `dokebi verify` and say done only when it is ok. Every command takes --json and changing commands take --dry-run. Outside a project run npx -y @dokebi/cli (inside, npx dokebi uses the project copy); the npm package named \"dokebi\" is not this CLI\n";
|
|
27
|
+
export declare const GUIDE_STEPS: readonly GuideStep[];
|
|
28
|
+
export declare const GUIDE_RULES: readonly string[];
|
|
29
|
+
export declare function buildGuide(catalog: Catalog): Guide;
|