@dokebi/cli 0.4.2 → 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.
Files changed (188) hide show
  1. package/AI.md +262 -57
  2. package/README.md +35 -10
  3. package/catalog.json +2519 -257
  4. package/describe.schema.json +748 -0
  5. package/dist/api-app.d.ts +2 -0
  6. package/dist/api-app.js +35 -0
  7. package/dist/app-urls.js +3 -2
  8. package/dist/bin.js +518 -35
  9. package/dist/capability-guide.d.ts +30 -0
  10. package/dist/capability-guide.js +122 -0
  11. package/dist/catalog.d.ts +16 -2
  12. package/dist/catalog.js +19 -7
  13. package/dist/check/asset-rules.d.ts +6 -0
  14. package/dist/check/asset-rules.js +163 -0
  15. package/dist/check/browser-sources.d.ts +20 -0
  16. package/dist/check/browser-sources.js +89 -0
  17. package/dist/check/console-rules.d.ts +6 -0
  18. package/dist/check/console-rules.js +40 -0
  19. package/dist/check/error-rules.d.ts +21 -0
  20. package/dist/check/error-rules.js +212 -0
  21. package/dist/check/form-rules.d.ts +12 -0
  22. package/dist/check/form-rules.js +69 -6
  23. package/dist/check/formatter-rules.d.ts +2 -0
  24. package/dist/check/formatter-rules.js +71 -0
  25. package/dist/check/i18n-rules.d.ts +27 -0
  26. package/dist/check/i18n-rules.js +353 -0
  27. package/dist/check/index.d.ts +15 -1
  28. package/dist/check/index.js +89 -11
  29. package/dist/check/mail-rules.d.ts +4 -0
  30. package/dist/check/mail-rules.js +72 -0
  31. package/dist/check/module-rules.d.ts +13 -0
  32. package/dist/check/module-rules.js +104 -0
  33. package/dist/check/packages.d.ts +8 -0
  34. package/dist/check/packages.js +16 -0
  35. package/dist/check/pool-rules.d.ts +8 -0
  36. package/dist/check/pool-rules.js +164 -0
  37. package/dist/check/problems.d.ts +6 -1
  38. package/dist/check/problems.js +100 -2
  39. package/dist/check/project-rules.d.ts +23 -3
  40. package/dist/check/project-rules.js +265 -23
  41. package/dist/check/seal-rules.d.ts +6 -0
  42. package/dist/check/seal-rules.js +68 -0
  43. package/dist/check/security-rules.d.ts +10 -0
  44. package/dist/check/security-rules.js +450 -0
  45. package/dist/check/shared-state-rules.d.ts +2 -0
  46. package/dist/check/shared-state-rules.js +114 -0
  47. package/dist/check/source-rules.d.ts +26 -3
  48. package/dist/check/source-rules.js +249 -28
  49. package/dist/check/structure-rules.d.ts +23 -0
  50. package/dist/check/structure-rules.js +502 -0
  51. package/dist/check/timer-rules.d.ts +7 -0
  52. package/dist/check/timer-rules.js +543 -0
  53. package/dist/check/typescript.d.ts +35 -5
  54. package/dist/check/typescript.js +163 -17
  55. package/dist/check/vite-rules.d.ts +2 -0
  56. package/dist/check/vite-rules.js +136 -0
  57. package/dist/check/wait-rules.d.ts +16 -0
  58. package/dist/check/wait-rules.js +337 -0
  59. package/dist/commands.d.ts +7 -0
  60. package/dist/commands.js +307 -31
  61. package/dist/config-diff.d.ts +42 -0
  62. package/dist/config-diff.js +289 -0
  63. package/dist/config-edit.d.ts +35 -2
  64. package/dist/config-edit.js +145 -13
  65. package/dist/config-init.d.ts +15 -0
  66. package/dist/config-init.js +61 -0
  67. package/dist/config-paths.d.ts +22 -0
  68. package/dist/config-paths.js +82 -0
  69. package/dist/config-show.d.ts +82 -0
  70. package/dist/config-show.js +563 -0
  71. package/dist/config-values.d.ts +8 -0
  72. package/dist/config-values.js +68 -0
  73. package/dist/configuration-scaffold.d.ts +42 -0
  74. package/dist/configuration-scaffold.js +258 -4
  75. package/dist/configuration.d.ts +63 -3
  76. package/dist/configuration.js +178 -4
  77. package/dist/deployment.d.ts +2 -0
  78. package/dist/deployment.js +111 -0
  79. package/dist/describe-db.d.ts +22 -0
  80. package/dist/describe-db.js +176 -0
  81. package/dist/describe.d.ts +92 -0
  82. package/dist/describe.js +417 -0
  83. package/dist/dev-compose.d.ts +20 -0
  84. package/dist/dev-compose.js +154 -0
  85. package/dist/dev-guard.js +44 -0
  86. package/dist/dev-host.d.ts +13 -0
  87. package/dist/dev-host.js +55 -0
  88. package/dist/dev-infra.d.ts +72 -4
  89. package/dist/dev-infra.js +237 -17
  90. package/dist/dev-watch.d.ts +34 -0
  91. package/dist/dev-watch.js +192 -0
  92. package/dist/diagnostics.d.ts +10 -7
  93. package/dist/diagnostics.js +22 -4
  94. package/dist/drift.d.ts +50 -0
  95. package/dist/drift.js +218 -0
  96. package/dist/env-file.d.ts +25 -1
  97. package/dist/env-file.js +64 -6
  98. package/dist/errors.d.ts +7 -1
  99. package/dist/example-config.d.ts +2 -0
  100. package/dist/example-config.js +3 -0
  101. package/dist/first-admin.d.ts +43 -0
  102. package/dist/first-admin.js +175 -0
  103. package/dist/formatter-ignore.d.ts +37 -0
  104. package/dist/formatter-ignore.js +344 -0
  105. package/dist/generator.d.ts +45 -3
  106. package/dist/generator.js +205 -15
  107. package/dist/guide.d.ts +1 -1
  108. package/dist/guide.js +42 -21
  109. package/dist/install-module.d.ts +20 -2
  110. package/dist/install-module.js +45 -8
  111. package/dist/install-plan.js +5 -3
  112. package/dist/layout.d.ts +12 -1
  113. package/dist/layout.js +19 -6
  114. package/dist/log-writer-control.d.ts +2 -0
  115. package/dist/log-writer-control.js +17 -2
  116. package/dist/log-writer.js +43 -10
  117. package/dist/logging-config.d.ts +8 -3
  118. package/dist/logging-config.js +19 -5
  119. package/dist/manifest.d.ts +29 -1
  120. package/dist/manifest.js +84 -6
  121. package/dist/merge-install-defaults.d.ts +0 -1
  122. package/dist/merge-install-defaults.js +13 -47
  123. package/dist/npm-client.d.ts +40 -4
  124. package/dist/npm-client.js +152 -15
  125. package/dist/npm-paths.js +18 -4
  126. package/dist/openapi.d.ts +27 -0
  127. package/dist/openapi.js +649 -0
  128. package/dist/option-fixes.d.ts +39 -0
  129. package/dist/option-fixes.js +130 -0
  130. package/dist/output.d.ts +1 -1
  131. package/dist/output.js +5 -5
  132. package/dist/plugin-lifecycle.d.ts +34 -2
  133. package/dist/plugin-lifecycle.js +195 -16
  134. package/dist/plugin-options.d.ts +19 -4
  135. package/dist/plugin-options.js +138 -6
  136. package/dist/plugins-command.js +44 -7
  137. package/dist/process-group.d.ts +39 -0
  138. package/dist/process-group.js +132 -0
  139. package/dist/project-guide.d.ts +19 -1
  140. package/dist/project-guide.js +119 -20
  141. package/dist/project-inspector.d.ts +15 -0
  142. package/dist/project-inspector.js +481 -33
  143. package/dist/project-state.d.ts +5 -0
  144. package/dist/project-state.js +8 -0
  145. package/dist/project.d.ts +203 -19
  146. package/dist/project.js +3006 -333
  147. package/dist/recipe-facts.d.ts +127 -0
  148. package/dist/recipe-facts.js +563 -0
  149. package/dist/recipe-login.d.ts +63 -0
  150. package/dist/recipe-login.js +464 -0
  151. package/dist/recipe-mail.d.ts +21 -0
  152. package/dist/recipe-mail.js +214 -0
  153. package/dist/recipe-members.d.ts +35 -0
  154. package/dist/recipe-members.js +178 -0
  155. package/dist/recipe-record.d.ts +82 -7
  156. package/dist/recipe-record.js +226 -22
  157. package/dist/recipe-reset.d.ts +2 -0
  158. package/dist/recipe-reset.js +117 -0
  159. package/dist/recipe-site.d.ts +48 -0
  160. package/dist/recipe-site.js +396 -0
  161. package/dist/recipe-step.d.ts +42 -0
  162. package/dist/recipe-step.js +89 -0
  163. package/dist/recipe.d.ts +56 -20
  164. package/dist/recipe.js +703 -131
  165. package/dist/registry.d.ts +98 -2
  166. package/dist/registry.js +171 -5
  167. package/dist/reloadable.d.ts +35 -0
  168. package/dist/reloadable.js +257 -0
  169. package/dist/server.d.ts +43 -4
  170. package/dist/server.js +601 -52
  171. package/dist/settings.d.ts +48 -0
  172. package/dist/settings.js +168 -0
  173. package/dist/site-auth.d.ts +39 -0
  174. package/dist/site-auth.js +195 -0
  175. package/dist/template-fixes.d.ts +26 -0
  176. package/dist/template-fixes.js +285 -0
  177. package/dist/test-reporter.d.ts +49 -0
  178. package/dist/test-reporter.js +190 -0
  179. package/dist/transaction.d.ts +47 -0
  180. package/dist/transaction.js +241 -7
  181. package/dist/verify-tests.d.ts +98 -0
  182. package/dist/verify-tests.js +449 -0
  183. package/dist/verify.d.ts +103 -3
  184. package/dist/verify.js +272 -21
  185. package/dist/view-choice.d.ts +58 -0
  186. package/dist/view-choice.js +131 -0
  187. package/dist/vite-watch.js +4 -2
  188. package/package.json +8 -6
@@ -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
+ }
@@ -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
- /** The connection URL from the variant template. */
25
- export declare function connectionUrl(docker: DockerSettings, password: string, port: number): string;
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 .env and the matching dokebi.yaml address. Docker data comes from the
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}}`, `\${${PASSWORD_VARIABLE}:?set ${PASSWORD_VARIABLE} in .env (npx dokebi dev-infra writes it)}`);
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 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
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 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}?`;
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 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.`,
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
+ }