@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/dev-host.js
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// Default development hosts, unique per project (D-0102). Browsers scope cookies by host, not by
|
|
2
|
+
// port, so two projects on `web.localhost:4410` and `web.localhost:4960` overwrite each other's
|
|
3
|
+
// session and XSRF-TOKEN cookies (AI-M24). Wherever the CLI picks a domain for an app itself (the
|
|
4
|
+
// first app of @dokebi/app, `app add --domain auto`, the blog/api recipes and the site recipe's
|
|
5
|
+
// `--domains auto`), it is `<app>.<project>.localhost`: the project label comes from the
|
|
6
|
+
// package.json name (else the folder name), cleaned to one DNS label. Every `*.localhost` name
|
|
7
|
+
// resolves to this machine (browsers, macOS and systemd resolvers), and verify connects to the
|
|
8
|
+
// listening address with the Host header, so no hosts file is needed. A domain the user names is
|
|
9
|
+
// never changed.
|
|
10
|
+
import { readFile } from 'node:fs/promises';
|
|
11
|
+
import { basename, join } from 'node:path';
|
|
12
|
+
import { FALLBACK_PROJECT_NAME } from './dev-infra.js';
|
|
13
|
+
import { debug, describeError, isMissing } from './diagnostics.js';
|
|
14
|
+
/** The answer that asks the CLI for the default development host (`--domain auto`, `--domains auto`). */
|
|
15
|
+
export const AUTO_DOMAIN = 'auto';
|
|
16
|
+
/** A DNS label (RFC 1035): lower-case letters, digits and inner dashes, at most 63 characters. */
|
|
17
|
+
export function hostLabel(value) {
|
|
18
|
+
return value
|
|
19
|
+
.toLowerCase()
|
|
20
|
+
.replace(/^@[^/]+\//, '')
|
|
21
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
22
|
+
.replace(/^-+/, '')
|
|
23
|
+
.slice(0, 63)
|
|
24
|
+
.replace(/-+$/, '');
|
|
25
|
+
}
|
|
26
|
+
/** The project's label: from the package.json name, else the folder name, else `dokebi-app`. */
|
|
27
|
+
export function projectHostLabel(packageName, folder) {
|
|
28
|
+
return ((typeof packageName === 'string' ? hostLabel(packageName) : '') ||
|
|
29
|
+
hostLabel(basename(folder)) ||
|
|
30
|
+
FALLBACK_PROJECT_NAME);
|
|
31
|
+
}
|
|
32
|
+
/** `<app>.<project>.localhost`: the development host of one app of one project. */
|
|
33
|
+
export function devHost(app, project) {
|
|
34
|
+
return `${hostLabel(app) || 'app'}.${project}.localhost`;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* The label of the project at `root` (D-0102). Before `init` (a recipe planning a new folder)
|
|
38
|
+
* there is no package.json yet: the folder name is what init writes as the package name.
|
|
39
|
+
*/
|
|
40
|
+
export async function readProjectHostLabel(root) {
|
|
41
|
+
let name;
|
|
42
|
+
try {
|
|
43
|
+
name = JSON.parse(await readFile(join(root, 'package.json'), 'utf8'))
|
|
44
|
+
.name;
|
|
45
|
+
}
|
|
46
|
+
catch (error) {
|
|
47
|
+
// No package.json yet (a recipe planning a new project): the folder name decides.
|
|
48
|
+
if (!isMissing(error))
|
|
49
|
+
throw error;
|
|
50
|
+
debug('Project host label from the folder name', { root, error: describeError(error) });
|
|
51
|
+
}
|
|
52
|
+
const label = projectHostLabel(name, root);
|
|
53
|
+
debug('Project host label', { root, label });
|
|
54
|
+
return label;
|
|
55
|
+
}
|
package/dist/dev-infra.d.ts
CHANGED
|
@@ -17,12 +17,78 @@ export declare function withEnv(text: string, name: string, value: string, comme
|
|
|
17
17
|
* user's project, not a plugin (it predates D-0070 and is kept so existing projects keep theirs).
|
|
18
18
|
*/
|
|
19
19
|
export declare const FALLBACK_PROJECT_NAME = "dokebi-app";
|
|
20
|
+
/**
|
|
21
|
+
* The variables compose.yaml takes from the environment without a default (`${NAME}`, `$NAME`,
|
|
22
|
+
* `${NAME:?…}`, `${NAME?…}`) that `dokebi dev-infra up` does not provide: it passes
|
|
23
|
+
* DOKEBI_DB_PASSWORD (from dokebi.yaml) and Compose reads the project's `.env`. R2 Codex T2: a
|
|
24
|
+
* compose.yaml rewritten to need BOOKING_DB_PORT from the project's own script made
|
|
25
|
+
* `dev-infra up` fail, and neither doctor nor the dev-infra plan said so.
|
|
26
|
+
*/
|
|
27
|
+
export declare function composeUnsetVariables(compose: string, envKeys?: Iterable<string>): string[];
|
|
28
|
+
/** The names a `.env` text sets (`NAME=` or `export NAME=`), read leniently: doctor reports an
|
|
29
|
+
* invalid `.env` on its own, so a line this cannot read is simply not a name. */
|
|
30
|
+
export declare function envKeys(text: string): string[];
|
|
31
|
+
/** The doctor and dev-infra advice for compose variables dev-infra up does not set. */
|
|
32
|
+
export declare function composeVariablesHint(variables: readonly string[]): string;
|
|
20
33
|
/** Compose project names: lower-case letters, digits, dashes and underscores. */
|
|
21
34
|
export declare function composeProject(name: string): string;
|
|
35
|
+
/**
|
|
36
|
+
* The replica set a development MongoDB runs as (D-0103): one member, so multi-document
|
|
37
|
+
* transactions work as they do in production. Other databases have none.
|
|
38
|
+
*/
|
|
39
|
+
export declare const MONGO_REPLICA_SET = "rs0";
|
|
40
|
+
export declare function replicaSetOf(docker: Pick<DockerSettings, 'image'>): string | undefined;
|
|
22
41
|
/** The compose.yaml text of the development database (the developer owns it afterwards). */
|
|
23
42
|
export declare function composeText(project: string, variant: string, docker: DockerSettings, port: number): string;
|
|
24
|
-
/**
|
|
25
|
-
|
|
43
|
+
/**
|
|
44
|
+
* The host port the `db` service of a kept compose.yaml publishes (its first `ports` entry):
|
|
45
|
+
* the short syntax (`127.0.0.1:18482:5432`) or the long one (`published: 18482`). `undefined`
|
|
46
|
+
* when the entry is missing or in another form (a variable, a range, a bare container port):
|
|
47
|
+
* the CLI cannot read it, and never rewrites it.
|
|
48
|
+
*/
|
|
49
|
+
export declare function composePublishedPort(text: string): number | undefined;
|
|
50
|
+
/**
|
|
51
|
+
* The compose.yaml services that publish host port `port`, reading `${NAME:-value}` and
|
|
52
|
+
* `${NAME-value}` as their value (a variable without one is not read). R3 Codex T2: the app
|
|
53
|
+
* itself was a service of compose.yaml on options.dokebi-web.port, so `dev-infra up` started it
|
|
54
|
+
* and `npx dokebi start` (and verify) then found the port taken.
|
|
55
|
+
*/
|
|
56
|
+
export declare function composeServicesPublishing(text: string, port: number): string[];
|
|
57
|
+
/**
|
|
58
|
+
* The compose.yaml services built from the project folder itself (`build: .`, or `build:` with
|
|
59
|
+
* `context: .` or no context): the app's own image. R4 Codex T4: its compose.yaml had the app as
|
|
60
|
+
* `build: .` with `\${DOKEBI_APP_PORT:?…}` as the port, so the port rule could not read it, and
|
|
61
|
+
* `dev-infra up` (which starts every service of the file) failed on the app's deploy variables.
|
|
62
|
+
* A service built from a subfolder (a database image with extensions) is infrastructure.
|
|
63
|
+
*/
|
|
64
|
+
export declare function composeServicesBuildingProject(text: string): string[];
|
|
65
|
+
/** The variables of `unset` that only `services` of compose.yaml use. */
|
|
66
|
+
export declare function composeVariablesOf(text: string, services: readonly string[], unset: readonly string[]): string[];
|
|
67
|
+
/** The doctor advice when compose.yaml builds and runs the app itself. */
|
|
68
|
+
export declare function composeBuildsAppHint(services: readonly string[], variables: readonly string[]): string;
|
|
69
|
+
/** The doctor advice when compose.yaml starts something on the app's own port. */
|
|
70
|
+
export declare function composeAppPortHint(services: readonly string[], port: number): string;
|
|
71
|
+
/**
|
|
72
|
+
* The same compose.yaml with only the published host port of the `db` service changed: the
|
|
73
|
+
* digits are replaced in the source text of that one scalar (the first `ports` entry, or its
|
|
74
|
+
* `published` key), so its address, container port, quoting and every other byte of the file
|
|
75
|
+
* stay. `undefined` when that entry is not in a form `composePublishedPort` reads.
|
|
76
|
+
*/
|
|
77
|
+
export declare function withComposePort(text: string, port: number): string | undefined;
|
|
78
|
+
/**
|
|
79
|
+
* Whether a kept compose service starts its server as a replica set (`--replSet` in its command
|
|
80
|
+
* or entrypoint). A compose.yaml written before D-0103 runs a standalone mongod.
|
|
81
|
+
*/
|
|
82
|
+
export declare function runsReplicaSet(service: {
|
|
83
|
+
command?: unknown;
|
|
84
|
+
entrypoint?: unknown;
|
|
85
|
+
}): boolean;
|
|
86
|
+
/**
|
|
87
|
+
* The connection URL from the variant template. With a replica set the driver is told its name
|
|
88
|
+
* and to talk to the one member at this address (`directConnection`), never to the member name
|
|
89
|
+
* the set reports, which only resolves inside the container.
|
|
90
|
+
*/
|
|
91
|
+
export declare function connectionUrl(docker: DockerSettings, password: string, port: number, replicaSet?: string): string;
|
|
26
92
|
/** The same URL with the password hidden, for results and logs. */
|
|
27
93
|
export declare function redactUrl(url: string): string;
|
|
28
94
|
/**
|
|
@@ -35,13 +101,15 @@ export declare function freePortAfter(port: number): Promise<number | undefined>
|
|
|
35
101
|
/**
|
|
36
102
|
* A taken port is the user's call (AI-M03): the question to ask and what to do now. `delegated`:
|
|
37
103
|
* 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.
|
|
104
|
+
* take the proposed port and must tell the user; otherwise it asks and ends its turn. `flag` is
|
|
105
|
+
* the flag of the command to run again (recipe, dev-infra), or the option a `config set` changes
|
|
106
|
+
* before the same command runs again (verify, AI-M04 F5).
|
|
39
107
|
*/
|
|
40
108
|
export declare function portQuestion(input: {
|
|
41
109
|
readonly what: 'Web' | 'Database';
|
|
42
110
|
readonly port: number;
|
|
43
111
|
readonly alternative: number | undefined;
|
|
44
|
-
readonly flag: '--port' | '--db-port';
|
|
112
|
+
readonly flag: '--port' | '--db-port' | 'dokebi-web.port';
|
|
45
113
|
readonly delegated: boolean;
|
|
46
114
|
}): {
|
|
47
115
|
waitForUser: boolean;
|
package/dist/dev-infra.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// Development infrastructure (D-0065): compose.yaml for the database a project uses, the
|
|
2
|
-
// generated password in
|
|
2
|
+
// generated password only in the dokebi.yaml address. Docker data comes from the
|
|
3
3
|
// db package's `dokebiCatalog.variants[].devInfra.docker`.
|
|
4
4
|
import { execFile } from 'node:child_process';
|
|
5
5
|
import { randomBytes } from 'node:crypto';
|
|
@@ -7,7 +7,8 @@ import { statSync } from 'node:fs';
|
|
|
7
7
|
import { connect, createServer } from 'node:net';
|
|
8
8
|
import { delimiter, join } from 'node:path';
|
|
9
9
|
import { promisify } from 'node:util';
|
|
10
|
-
import { stringify } from 'yaml';
|
|
10
|
+
import { isMap, isScalar, parseDocument, stringify } from 'yaml';
|
|
11
|
+
import { databaseEnvironment } from './dev-compose.js';
|
|
11
12
|
import { debug, describeError, isMissing } from './diagnostics.js';
|
|
12
13
|
const exec = promisify(execFile);
|
|
13
14
|
export const COMPOSE_FILE = 'compose.yaml';
|
|
@@ -38,6 +39,41 @@ export function withEnv(text, name, value, comment) {
|
|
|
38
39
|
* user's project, not a plugin (it predates D-0070 and is kept so existing projects keep theirs).
|
|
39
40
|
*/
|
|
40
41
|
export const FALLBACK_PROJECT_NAME = 'dokebi-app';
|
|
42
|
+
/**
|
|
43
|
+
* The variables compose.yaml takes from the environment without a default (`${NAME}`, `$NAME`,
|
|
44
|
+
* `${NAME:?…}`, `${NAME?…}`) that `dokebi dev-infra up` does not provide: it passes
|
|
45
|
+
* DOKEBI_DB_PASSWORD (from dokebi.yaml) and Compose reads the project's `.env`. R2 Codex T2: a
|
|
46
|
+
* compose.yaml rewritten to need BOOKING_DB_PORT from the project's own script made
|
|
47
|
+
* `dev-infra up` fail, and neither doctor nor the dev-infra plan said so.
|
|
48
|
+
*/
|
|
49
|
+
export function composeUnsetVariables(compose, envKeys = []) {
|
|
50
|
+
const provided = new Set([PASSWORD_VARIABLE, ...envKeys]);
|
|
51
|
+
const text = compose
|
|
52
|
+
.split('\n')
|
|
53
|
+
.filter((line) => !line.trimStart().startsWith('#'))
|
|
54
|
+
.join('\n')
|
|
55
|
+
.replaceAll('$$', '');
|
|
56
|
+
const unset = new Set();
|
|
57
|
+
for (const match of text.matchAll(/\$\{([A-Za-z_][A-Za-z0-9_]*)(?:(:?[-+?])[^}]*)?\}|\$([A-Za-z_][A-Za-z0-9_]*)/g)) {
|
|
58
|
+
const name = match[1] ?? match[3];
|
|
59
|
+
const operator = match[2];
|
|
60
|
+
// A default (`-`) or an alternative (`+`) never fails; `?` and a bare name need a value.
|
|
61
|
+
if (name === undefined || operator?.includes('-') || operator?.includes('+'))
|
|
62
|
+
continue;
|
|
63
|
+
if (!provided.has(name))
|
|
64
|
+
unset.add(name);
|
|
65
|
+
}
|
|
66
|
+
return [...unset].sort();
|
|
67
|
+
}
|
|
68
|
+
/** The names a `.env` text sets (`NAME=` or `export NAME=`), read leniently: doctor reports an
|
|
69
|
+
* invalid `.env` on its own, so a line this cannot read is simply not a name. */
|
|
70
|
+
export function envKeys(text) {
|
|
71
|
+
return [...text.matchAll(/^[ \t]*(?:export[ \t]+)?([A-Za-z_][A-Za-z0-9_]*)[ \t]*=/gm)].map((match) => match[1]);
|
|
72
|
+
}
|
|
73
|
+
/** The doctor and dev-infra advice for compose variables dev-infra up does not set. */
|
|
74
|
+
export function composeVariablesHint(variables) {
|
|
75
|
+
return `compose.yaml needs ${variables.join(', ')} from the environment, which npx dokebi dev-infra up does not set, so dev-infra up fails. dev-infra up passes the passwords of dokebi.yaml: ${PASSWORD_VARIABLE}, and for every connection DOKEBI_<PLUGIN>_<CONNECTION>_PASSWORD (or _TOKEN; without the connection for main, such as DOKEBI_REDIS_PASSWORD); use those names, give a default in compose.yaml (\${NAME:-value}) or set it in .env`;
|
|
76
|
+
}
|
|
41
77
|
/** Compose project names: lower-case letters, digits, dashes and underscores. */
|
|
42
78
|
export function composeProject(name) {
|
|
43
79
|
const cleaned = name
|
|
@@ -55,20 +91,60 @@ function dataDirectory(image) {
|
|
|
55
91
|
return '/data/db';
|
|
56
92
|
return '/var/lib/mysql';
|
|
57
93
|
}
|
|
94
|
+
/**
|
|
95
|
+
* The replica set a development MongoDB runs as (D-0103): one member, so multi-document
|
|
96
|
+
* transactions work as they do in production. Other databases have none.
|
|
97
|
+
*/
|
|
98
|
+
export const MONGO_REPLICA_SET = 'rs0';
|
|
99
|
+
export function replicaSetOf(docker) {
|
|
100
|
+
return docker.image.startsWith('mongo') ? MONGO_REPLICA_SET : undefined;
|
|
101
|
+
}
|
|
102
|
+
/** Where the generated mongod keeps its key file: inside the container, written at each start. */
|
|
103
|
+
const MONGO_KEY_FILE = '/tmp/dokebi-replica-set.key';
|
|
104
|
+
/**
|
|
105
|
+
* The mongod start and readiness check of a single-node replica set. With authentication
|
|
106
|
+
* (`MONGO_INITDB_ROOT_USERNAME`) mongod requires a key file for a replica set; one member never
|
|
107
|
+
* authenticates to another, so a fresh random key at each container start is enough. The
|
|
108
|
+
* healthcheck initiates the set once (`NotYetInitialized` only: during the image's first-run
|
|
109
|
+
* initialisation the temporary server has no replica set and the check just fails) and passes
|
|
110
|
+
* only when this member is the writable primary, so `up --wait` returns when transactions work.
|
|
111
|
+
*/
|
|
112
|
+
function replicaSetService(docker, name) {
|
|
113
|
+
const auth = Object.hasOwn(docker.env, 'MONGO_INITDB_ROOT_USERNAME');
|
|
114
|
+
const mongod = `docker-entrypoint.sh mongod --replSet ${name} --bind_ip_all${auth ? ` --keyFile ${MONGO_KEY_FILE}` : ''}`;
|
|
115
|
+
const start = auth
|
|
116
|
+
? `head -c 756 /dev/urandom | base64 > ${MONGO_KEY_FILE} && chmod 400 ${MONGO_KEY_FILE} && chown mongodb:mongodb ${MONGO_KEY_FILE} && exec ${mongod}`
|
|
117
|
+
: `exec ${mongod}`;
|
|
118
|
+
// `$$` is a literal `$` for Compose: the shell of the container reads its own variables.
|
|
119
|
+
const login = auth
|
|
120
|
+
? ' -u "$$MONGO_INITDB_ROOT_USERNAME" -p "$$MONGO_INITDB_ROOT_PASSWORD" --authenticationDatabase admin'
|
|
121
|
+
: '';
|
|
122
|
+
const check = `try { rs.status() } catch (e) { if (e.codeName !== 'NotYetInitialized') throw e; rs.initiate({ _id: '${name}', members: [{ _id: 0, host: '127.0.0.1:${docker.port}' }] }) } if (!db.hello().isWritablePrimary) quit(1)`;
|
|
123
|
+
return {
|
|
124
|
+
entrypoint: ['bash', '-c', start],
|
|
125
|
+
healthcheck: ['CMD-SHELL', `mongosh --quiet${login} --eval "${check}"`],
|
|
126
|
+
};
|
|
127
|
+
}
|
|
58
128
|
/** The compose.yaml text of the development database (the developer owns it afterwards). */
|
|
59
129
|
export function composeText(project, variant, docker, port) {
|
|
60
|
-
const interpolate = (value) => value.replaceAll(`\${${PASSWORD_VARIABLE}}`,
|
|
130
|
+
const interpolate = (value) => value.replaceAll(`\${${PASSWORD_VARIABLE}}`,
|
|
131
|
+
// Compose refuses every command without the password (an empty one would start the
|
|
132
|
+
// database open); the message names the commands that pass it from dokebi.yaml.
|
|
133
|
+
`\${${PASSWORD_VARIABLE}:?run it as npx dokebi dev-infra up or down, which pass the password from dokebi.yaml}`);
|
|
134
|
+
const replicaSet = replicaSetOf(docker);
|
|
135
|
+
const cluster = replicaSet ? replicaSetService(docker, replicaSet) : undefined;
|
|
61
136
|
const document = {
|
|
62
137
|
name: project,
|
|
63
138
|
services: {
|
|
64
139
|
[DB_SERVICE]: {
|
|
65
140
|
image: docker.image,
|
|
66
141
|
restart: 'unless-stopped',
|
|
142
|
+
...(cluster ? { entrypoint: cluster.entrypoint } : {}),
|
|
67
143
|
environment: Object.fromEntries(Object.entries(docker.env).map(([key, value]) => [key, interpolate(value)])),
|
|
68
144
|
ports: [`127.0.0.1:${port}:${docker.port}`],
|
|
69
145
|
volumes: [`db-data:${dataDirectory(docker.image)}`],
|
|
70
146
|
healthcheck: {
|
|
71
|
-
test: docker.healthcheck.map(interpolate),
|
|
147
|
+
test: cluster ? cluster.healthcheck : docker.healthcheck.map(interpolate),
|
|
72
148
|
interval: '2s',
|
|
73
149
|
timeout: '5s',
|
|
74
150
|
retries: 60,
|
|
@@ -77,17 +153,155 @@ export function composeText(project, variant, docker, port) {
|
|
|
77
153
|
},
|
|
78
154
|
volumes: { 'db-data': {} },
|
|
79
155
|
};
|
|
156
|
+
const clusterNote = replicaSet
|
|
157
|
+
? `# MongoDB runs as the single-node replica set ${replicaSet}, so multi-document transactions work;
|
|
158
|
+
# the healthcheck initiates it once. Connect with replicaSet=${replicaSet}&directConnection=true.
|
|
159
|
+
`
|
|
160
|
+
: '';
|
|
80
161
|
return `# Development ${variant} database of this project, written by \`npx dokebi dev-infra\`.
|
|
81
|
-
# It is yours to change. The password
|
|
82
|
-
#
|
|
83
|
-
#
|
|
84
|
-
${stringify(document, { lineWidth: 0 })}`;
|
|
85
|
-
}
|
|
86
|
-
/** The
|
|
87
|
-
|
|
88
|
-
|
|
162
|
+
# It is yours to change. The password comes from dokebi.yaml through the CLI child environment.
|
|
163
|
+
# Start: npx dokebi dev-infra up · Stop: npx dokebi dev-infra down
|
|
164
|
+
# Named data volumes are retained by down.
|
|
165
|
+
${clusterNote}${stringify(document, { lineWidth: 0 })}`;
|
|
166
|
+
}
|
|
167
|
+
/** The short-syntax port entry `[ip:]published:target[/protocol]` of a compose service. */
|
|
168
|
+
const SHORT_PORT = /^((?:[^:]*:)?)(\d+)(:\d+(?:\/[a-z]+)?)$/;
|
|
169
|
+
/**
|
|
170
|
+
* The host port the `db` service of a kept compose.yaml publishes (its first `ports` entry):
|
|
171
|
+
* the short syntax (`127.0.0.1:18482:5432`) or the long one (`published: 18482`). `undefined`
|
|
172
|
+
* when the entry is missing or in another form (a variable, a range, a bare container port):
|
|
173
|
+
* the CLI cannot read it, and never rewrites it.
|
|
174
|
+
*/
|
|
175
|
+
export function composePublishedPort(text) {
|
|
176
|
+
const entry = parseDocument(text).getIn(['services', DB_SERVICE, 'ports', 0], true);
|
|
177
|
+
if (isScalar(entry)) {
|
|
178
|
+
const match = SHORT_PORT.exec(String(entry.value));
|
|
179
|
+
return match ? Number(match[2]) : undefined;
|
|
180
|
+
}
|
|
181
|
+
if (isMap(entry)) {
|
|
182
|
+
const published = Number(String(entry.get('published') ?? ''));
|
|
183
|
+
return Number.isSafeInteger(published) && published > 0 ? published : undefined;
|
|
184
|
+
}
|
|
185
|
+
return undefined;
|
|
186
|
+
}
|
|
187
|
+
/**
|
|
188
|
+
* The compose.yaml services that publish host port `port`, reading `${NAME:-value}` and
|
|
189
|
+
* `${NAME-value}` as their value (a variable without one is not read). R3 Codex T2: the app
|
|
190
|
+
* itself was a service of compose.yaml on options.dokebi-web.port, so `dev-infra up` started it
|
|
191
|
+
* and `npx dokebi start` (and verify) then found the port taken.
|
|
192
|
+
*/
|
|
193
|
+
export function composeServicesPublishing(text, port) {
|
|
194
|
+
const services = parseDocument(text).get('services', true);
|
|
195
|
+
if (!isMap(services))
|
|
196
|
+
return [];
|
|
197
|
+
const value = (raw) => String(raw ?? '').replaceAll(/\$\{[A-Za-z_][A-Za-z0-9_]*:?-([^}]*)\}/g, '$1');
|
|
198
|
+
const found = [];
|
|
199
|
+
for (const item of services.items) {
|
|
200
|
+
const ports = isMap(item.value) ? item.value.get('ports', true) : undefined;
|
|
201
|
+
if (!ports || typeof ports !== 'object' || !('items' in ports))
|
|
202
|
+
continue;
|
|
203
|
+
const published = ports.items.map((entry) => {
|
|
204
|
+
if (isScalar(entry))
|
|
205
|
+
return SHORT_PORT.exec(value(entry.value))?.[2];
|
|
206
|
+
if (isMap(entry))
|
|
207
|
+
return value(entry.get('published'));
|
|
208
|
+
return undefined;
|
|
209
|
+
});
|
|
210
|
+
if (published.some((host) => host !== undefined && Number(host) === port))
|
|
211
|
+
found.push(String(isScalar(item.key) ? item.key.value : item.key));
|
|
212
|
+
}
|
|
213
|
+
return found;
|
|
214
|
+
}
|
|
215
|
+
/**
|
|
216
|
+
* The compose.yaml services built from the project folder itself (`build: .`, or `build:` with
|
|
217
|
+
* `context: .` or no context): the app's own image. R4 Codex T4: its compose.yaml had the app as
|
|
218
|
+
* `build: .` with `\${DOKEBI_APP_PORT:?…}` as the port, so the port rule could not read it, and
|
|
219
|
+
* `dev-infra up` (which starts every service of the file) failed on the app's deploy variables.
|
|
220
|
+
* A service built from a subfolder (a database image with extensions) is infrastructure.
|
|
221
|
+
*/
|
|
222
|
+
export function composeServicesBuildingProject(text) {
|
|
223
|
+
const services = parseDocument(text).get('services', true);
|
|
224
|
+
if (!isMap(services))
|
|
225
|
+
return [];
|
|
226
|
+
const root = (raw) => {
|
|
227
|
+
const path = String(raw ?? '.').trim();
|
|
228
|
+
return path === '' || path === '.' || path === './';
|
|
229
|
+
};
|
|
230
|
+
const found = [];
|
|
231
|
+
for (const item of services.items) {
|
|
232
|
+
const build = isMap(item.value) ? item.value.get('build', true) : undefined;
|
|
233
|
+
if (build === undefined)
|
|
234
|
+
continue;
|
|
235
|
+
const local = isScalar(build)
|
|
236
|
+
? root(build.value)
|
|
237
|
+
: isMap(build)
|
|
238
|
+
? root(build.get('context'))
|
|
239
|
+
: false;
|
|
240
|
+
if (local)
|
|
241
|
+
found.push(String(isScalar(item.key) ? item.key.value : item.key));
|
|
242
|
+
}
|
|
243
|
+
return found;
|
|
244
|
+
}
|
|
245
|
+
/** The variables of `unset` that only `services` of compose.yaml use. */
|
|
246
|
+
export function composeVariablesOf(text, services, unset) {
|
|
247
|
+
const document = parseDocument(text);
|
|
248
|
+
for (const service of services)
|
|
249
|
+
document.deleteIn(['services', service]);
|
|
250
|
+
const left = new Set(composeUnsetVariables(String(document)));
|
|
251
|
+
return unset.filter((name) => !left.has(name));
|
|
252
|
+
}
|
|
253
|
+
/** The doctor advice when compose.yaml builds and runs the app itself. */
|
|
254
|
+
export function composeBuildsAppHint(services, variables) {
|
|
255
|
+
const theirs = variables.length
|
|
256
|
+
? ` (the variables COMPOSE_VARIABLES_UNSET names, ${variables.join(', ')}, go with ${services.length > 1 ? 'them' : 'it'})`
|
|
257
|
+
: '';
|
|
258
|
+
return `compose.yaml service ${services.join(', ')} is built from this project (build: .): npx dokebi dev-infra up builds and starts it with the infrastructure, and npx dokebi start, dev and verify then run the app a second time beside it (or find its port taken), or dev-infra up fails on what only the app needs${theirs}. compose.yaml is the development infrastructure (databases, Redis, NATS, mail): keep a container of the app in its own Compose file (compose.deploy.yaml from npx dokebi init --deploy, started with docker compose -f compose.deploy.yaml)`;
|
|
259
|
+
}
|
|
260
|
+
/** The doctor advice when compose.yaml starts something on the app's own port. */
|
|
261
|
+
export function composeAppPortHint(services, port) {
|
|
262
|
+
return `compose.yaml service ${services.join(', ')} publishes port ${port}, the port of options.dokebi-web.port: npx dokebi dev-infra up starts it, and npx dokebi start, dev and verify then find the port taken (PORT_IN_USE). compose.yaml is the development infrastructure (databases, Redis, NATS, mail); keep a container of the app in its own Compose file (for example compose.deploy.yaml from npx dokebi init --deploy) or give it another host port`;
|
|
263
|
+
}
|
|
264
|
+
/**
|
|
265
|
+
* The same compose.yaml with only the published host port of the `db` service changed: the
|
|
266
|
+
* digits are replaced in the source text of that one scalar (the first `ports` entry, or its
|
|
267
|
+
* `published` key), so its address, container port, quoting and every other byte of the file
|
|
268
|
+
* stay. `undefined` when that entry is not in a form `composePublishedPort` reads.
|
|
269
|
+
*/
|
|
270
|
+
export function withComposePort(text, port) {
|
|
271
|
+
if (composePublishedPort(text) === undefined)
|
|
272
|
+
return undefined;
|
|
273
|
+
const entry = parseDocument(text).getIn(['services', DB_SERVICE, 'ports', 0], true);
|
|
274
|
+
const scalar = isMap(entry) ? entry.get('published', true) : entry;
|
|
275
|
+
if (!isScalar(scalar) || !scalar.range)
|
|
276
|
+
return undefined;
|
|
277
|
+
const [from, to] = scalar.range;
|
|
278
|
+
const source = text.slice(from, to);
|
|
279
|
+
const pattern = isMap(entry)
|
|
280
|
+
? /^(["']?)(\d+)(["']?)$/
|
|
281
|
+
: /^(["']?(?:[^:"']*:)?)(\d+)(:\d+(?:\/[a-z]+)?["']?)$/;
|
|
282
|
+
if (!pattern.test(source))
|
|
283
|
+
return undefined;
|
|
284
|
+
return `${text.slice(0, from)}${source.replace(pattern, `$1${port}$3`)}${text.slice(to)}`;
|
|
285
|
+
}
|
|
286
|
+
/**
|
|
287
|
+
* Whether a kept compose service starts its server as a replica set (`--replSet` in its command
|
|
288
|
+
* or entrypoint). A compose.yaml written before D-0103 runs a standalone mongod.
|
|
289
|
+
*/
|
|
290
|
+
export function runsReplicaSet(service) {
|
|
291
|
+
return /--replSet\b/.test(`${JSON.stringify(service.command)} ${JSON.stringify(service.entrypoint)}`);
|
|
292
|
+
}
|
|
293
|
+
/**
|
|
294
|
+
* The connection URL from the variant template. With a replica set the driver is told its name
|
|
295
|
+
* and to talk to the one member at this address (`directConnection`), never to the member name
|
|
296
|
+
* the set reports, which only resolves inside the container.
|
|
297
|
+
*/
|
|
298
|
+
export function connectionUrl(docker, password, port, replicaSet) {
|
|
299
|
+
const url = docker.url
|
|
89
300
|
.replace('{password}', encodeURIComponent(password))
|
|
90
301
|
.replace('{port}', String(port));
|
|
302
|
+
if (!replicaSet)
|
|
303
|
+
return url;
|
|
304
|
+
return `${url}${url.includes('?') ? '&' : '?'}replicaSet=${encodeURIComponent(replicaSet)}&directConnection=true`;
|
|
91
305
|
}
|
|
92
306
|
/** The same URL with the password hidden, for results and logs. */
|
|
93
307
|
export function redactUrl(url) {
|
|
@@ -136,19 +350,25 @@ export async function freePortAfter(port) {
|
|
|
136
350
|
/**
|
|
137
351
|
* A taken port is the user's call (AI-M03): the question to ask and what to do now. `delegated`:
|
|
138
352
|
* 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.
|
|
353
|
+
* take the proposed port and must tell the user; otherwise it asks and ends its turn. `flag` is
|
|
354
|
+
* the flag of the command to run again (recipe, dev-infra), or the option a `config set` changes
|
|
355
|
+
* before the same command runs again (verify, AI-M04 F5).
|
|
140
356
|
*/
|
|
141
357
|
export function portQuestion(input) {
|
|
142
358
|
const { what, port, alternative, flag, delegated } = input;
|
|
143
359
|
const proposal = alternative ?? '<free port>';
|
|
144
|
-
const
|
|
360
|
+
const use = (value) => flag.startsWith('--') ? `${flag} ${value}` : `npx dokebi config set ${flag}=${value}`;
|
|
361
|
+
const again = (value) => flag.startsWith('--')
|
|
362
|
+
? `run the same command again with ${use(value)}`
|
|
363
|
+
: `run ${use(value)} (--dry-run first), then the same command again`;
|
|
364
|
+
const question = `${what} port ${port} is in use by another program. Use port ${proposal} instead (${use(proposal)}), or stop the program that uses ${port} and keep ${port}?`;
|
|
145
365
|
const rule = 'do not pick the port yourself unless the user left choices to you (--defaults / --answers-from defaults)';
|
|
146
366
|
return {
|
|
147
367
|
waitForUser: !delegated,
|
|
148
368
|
question,
|
|
149
369
|
next: delegated
|
|
150
|
-
? `The user left the choices to you (--defaults), so you may
|
|
151
|
-
: `Ask the user this question and end your turn; ${rule}. Nothing was changed. After the answer,
|
|
370
|
+
? `The user left the choices to you (--defaults), so you may ${again(proposal)}; tell the user the port you chose and why (${port} was in use). Nothing was changed. Otherwise ${rule}.`
|
|
371
|
+
: `Ask the user this question and end your turn; ${rule}. Nothing was changed. After the answer, ${again('<the chosen port>')}, or as it was once the user stopped the other program.`,
|
|
152
372
|
};
|
|
153
373
|
}
|
|
154
374
|
/**
|
|
@@ -204,7 +424,7 @@ export async function dockerAvailable() {
|
|
|
204
424
|
/** Whether the compose service of this project runs (a later recipe run skips `up`). */
|
|
205
425
|
export async function composeServiceRunning(root) {
|
|
206
426
|
try {
|
|
207
|
-
const { stdout } = await exec('docker', ['compose', 'ps', '--status', 'running', '--services'], { cwd: root, timeout: 15_000 });
|
|
427
|
+
const { stdout } = await exec('docker', ['compose', 'ps', '--status', 'running', '--services'], { cwd: root, timeout: 15_000, env: { ...process.env, ...(await databaseEnvironment(root)) } });
|
|
208
428
|
return stdout.split('\n').includes(DB_SERVICE);
|
|
209
429
|
}
|
|
210
430
|
catch (error) {
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The recorded state of the watched paths. `changed(path)` answers whether a reported path really
|
|
3
|
+
* changed since it was recorded (or since its last change) and records its new state.
|
|
4
|
+
*/
|
|
5
|
+
export declare class WatchedState {
|
|
6
|
+
#private;
|
|
7
|
+
/**
|
|
8
|
+
* @param skip paths that are neither recorded nor compared (files whose events the caller drops,
|
|
9
|
+
* such as views served by Vite).
|
|
10
|
+
*/
|
|
11
|
+
constructor(options?: {
|
|
12
|
+
limit?: number;
|
|
13
|
+
skip?: (path: string) => boolean;
|
|
14
|
+
});
|
|
15
|
+
/** Whether every path under the recorded folders is known (false past the limit). */
|
|
16
|
+
get complete(): boolean;
|
|
17
|
+
/** Events answered "no change" so far. */
|
|
18
|
+
get ignored(): number;
|
|
19
|
+
/** Records a file or folder (not its contents); a missing one is recorded as absent. */
|
|
20
|
+
record(path: string): void;
|
|
21
|
+
/** Records a folder and everything under it (symbolic links are not followed into). */
|
|
22
|
+
recordTree(folder: string): void;
|
|
23
|
+
/**
|
|
24
|
+
* Whether the path an fs.watch event on `folder` names changed (see `changed`). The folder's own
|
|
25
|
+
* name that is not an entry of it means the folder itself (libuv).
|
|
26
|
+
*/
|
|
27
|
+
changedIn(folder: string, name: string): boolean;
|
|
28
|
+
/**
|
|
29
|
+
* Whether `path` differs from its recorded state; the new state is recorded. A path that is
|
|
30
|
+
* absent now and was absent (or unknown with a complete record) did not change; an unchanged
|
|
31
|
+
* folder changed when an entry below it did.
|
|
32
|
+
*/
|
|
33
|
+
changed(path: string): boolean;
|
|
34
|
+
}
|