@dokebi/cli 0.4.2 → 0.4.27

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 (200) hide show
  1. package/AI.md +279 -57
  2. package/README.md +35 -10
  3. package/catalog.json +2520 -258
  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 +638 -37
  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 +15 -0
  46. package/dist/check/shared-state-rules.js +117 -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 +381 -36
  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-secrets.d.ts +46 -0
  70. package/dist/config-secrets.js +187 -0
  71. package/dist/config-show.d.ts +82 -0
  72. package/dist/config-show.js +563 -0
  73. package/dist/config-values.d.ts +8 -0
  74. package/dist/config-values.js +68 -0
  75. package/dist/configuration-scaffold.d.ts +42 -0
  76. package/dist/configuration-scaffold.js +258 -4
  77. package/dist/configuration.d.ts +63 -3
  78. package/dist/configuration.js +178 -4
  79. package/dist/deploy-scripts.d.ts +4 -0
  80. package/dist/deploy-scripts.js +361 -0
  81. package/dist/deploy-templates.d.ts +46 -0
  82. package/dist/deploy-templates.js +379 -0
  83. package/dist/deploy.d.ts +46 -0
  84. package/dist/deploy.js +592 -0
  85. package/dist/deployment.d.ts +2 -0
  86. package/dist/deployment.js +111 -0
  87. package/dist/describe-db.d.ts +22 -0
  88. package/dist/describe-db.js +176 -0
  89. package/dist/describe.d.ts +92 -0
  90. package/dist/describe.js +417 -0
  91. package/dist/dev-compose.d.ts +20 -0
  92. package/dist/dev-compose.js +154 -0
  93. package/dist/dev-guard.js +44 -0
  94. package/dist/dev-host.d.ts +13 -0
  95. package/dist/dev-host.js +55 -0
  96. package/dist/dev-infra.d.ts +72 -4
  97. package/dist/dev-infra.js +237 -17
  98. package/dist/dev-watch.d.ts +34 -0
  99. package/dist/dev-watch.js +192 -0
  100. package/dist/diagnostics.d.ts +10 -7
  101. package/dist/diagnostics.js +22 -4
  102. package/dist/drift.d.ts +50 -0
  103. package/dist/drift.js +218 -0
  104. package/dist/env-file.d.ts +25 -1
  105. package/dist/env-file.js +64 -6
  106. package/dist/errors.d.ts +7 -1
  107. package/dist/example-config.d.ts +2 -0
  108. package/dist/example-config.js +3 -0
  109. package/dist/first-admin.d.ts +43 -0
  110. package/dist/first-admin.js +175 -0
  111. package/dist/formatter-ignore.d.ts +37 -0
  112. package/dist/formatter-ignore.js +344 -0
  113. package/dist/generator.d.ts +45 -3
  114. package/dist/generator.js +205 -15
  115. package/dist/guide.d.ts +1 -1
  116. package/dist/guide.js +42 -21
  117. package/dist/install-module.d.ts +20 -2
  118. package/dist/install-module.js +45 -8
  119. package/dist/install-plan.js +5 -3
  120. package/dist/layout.d.ts +12 -1
  121. package/dist/layout.js +19 -6
  122. package/dist/log-writer-control.d.ts +2 -0
  123. package/dist/log-writer-control.js +17 -2
  124. package/dist/log-writer.js +43 -10
  125. package/dist/logging-config.d.ts +8 -3
  126. package/dist/logging-config.js +19 -5
  127. package/dist/manifest.d.ts +29 -1
  128. package/dist/manifest.js +84 -6
  129. package/dist/merge-install-defaults.d.ts +0 -1
  130. package/dist/merge-install-defaults.js +13 -47
  131. package/dist/npm-client.d.ts +40 -4
  132. package/dist/npm-client.js +152 -15
  133. package/dist/npm-paths.js +18 -4
  134. package/dist/openapi.d.ts +27 -0
  135. package/dist/openapi.js +649 -0
  136. package/dist/option-fixes.d.ts +39 -0
  137. package/dist/option-fixes.js +130 -0
  138. package/dist/output.d.ts +1 -1
  139. package/dist/output.js +5 -5
  140. package/dist/plugin-lifecycle.d.ts +34 -2
  141. package/dist/plugin-lifecycle.js +195 -16
  142. package/dist/plugin-options.d.ts +19 -4
  143. package/dist/plugin-options.js +138 -6
  144. package/dist/plugins-command.js +44 -7
  145. package/dist/process-group.d.ts +39 -0
  146. package/dist/process-group.js +132 -0
  147. package/dist/production-doctor.d.ts +3 -0
  148. package/dist/production-doctor.js +176 -0
  149. package/dist/project-guide.d.ts +19 -1
  150. package/dist/project-guide.js +119 -20
  151. package/dist/project-inspector.d.ts +15 -0
  152. package/dist/project-inspector.js +497 -33
  153. package/dist/project-state.d.ts +5 -0
  154. package/dist/project-state.js +8 -0
  155. package/dist/project.d.ts +203 -19
  156. package/dist/project.js +3006 -333
  157. package/dist/recipe-facts.d.ts +127 -0
  158. package/dist/recipe-facts.js +563 -0
  159. package/dist/recipe-login.d.ts +63 -0
  160. package/dist/recipe-login.js +464 -0
  161. package/dist/recipe-mail.d.ts +21 -0
  162. package/dist/recipe-mail.js +214 -0
  163. package/dist/recipe-members.d.ts +35 -0
  164. package/dist/recipe-members.js +178 -0
  165. package/dist/recipe-record.d.ts +82 -7
  166. package/dist/recipe-record.js +226 -22
  167. package/dist/recipe-reset.d.ts +2 -0
  168. package/dist/recipe-reset.js +117 -0
  169. package/dist/recipe-site.d.ts +48 -0
  170. package/dist/recipe-site.js +396 -0
  171. package/dist/recipe-step.d.ts +42 -0
  172. package/dist/recipe-step.js +89 -0
  173. package/dist/recipe.d.ts +56 -20
  174. package/dist/recipe.js +703 -131
  175. package/dist/registry.d.ts +98 -2
  176. package/dist/registry.js +171 -5
  177. package/dist/reloadable.d.ts +35 -0
  178. package/dist/reloadable.js +257 -0
  179. package/dist/remote-verify.d.ts +31 -0
  180. package/dist/remote-verify.js +204 -0
  181. package/dist/server.d.ts +43 -4
  182. package/dist/server.js +601 -52
  183. package/dist/settings.d.ts +48 -0
  184. package/dist/settings.js +168 -0
  185. package/dist/site-auth.d.ts +39 -0
  186. package/dist/site-auth.js +195 -0
  187. package/dist/template-fixes.d.ts +26 -0
  188. package/dist/template-fixes.js +285 -0
  189. package/dist/test-reporter.d.ts +49 -0
  190. package/dist/test-reporter.js +190 -0
  191. package/dist/transaction.d.ts +47 -0
  192. package/dist/transaction.js +241 -7
  193. package/dist/verify-tests.d.ts +98 -0
  194. package/dist/verify-tests.js +449 -0
  195. package/dist/verify.d.ts +103 -3
  196. package/dist/verify.js +272 -21
  197. package/dist/view-choice.d.ts +58 -0
  198. package/dist/view-choice.js +131 -0
  199. package/dist/vite-watch.js +4 -2
  200. package/package.json +8 -6
@@ -0,0 +1,192 @@
1
+ // What `dokebi dev` restarts for. A file event alone is not proof of a change: on macOS, FSEvents
2
+ // also delivers events that were still queued in fseventsd when the watch started, and a busy
3
+ // fseventsd can deliver them tens of seconds late. After `dokebi init`, `dokebi add`, `npm install`
4
+ // or a `git checkout` right before `dokebi dev`, those events restarted the app again and again,
5
+ // and every restart closes its port for a moment. libuv also names an event on the watched
6
+ // folder itself by the folder's own name (a `src/src` that does not exist).
7
+ //
8
+ // So dev records the state of every watched path before the first app start (the app reads its
9
+ // files after that) and restarts only when a reported path's state differs from the recorded one:
10
+ // a new, deleted, replaced or rewritten file or folder. An event on a folder whose own state did
11
+ // not change means "something below may have changed" (FSEvents coalesces events onto folders),
12
+ // so the folder is compared entry by entry. The state of a file is its device, inode, size and
13
+ // modification time, of a folder its device and inode; access and attribute-only changes do not
14
+ // restart.
15
+ import { readdirSync, statSync } from 'node:fs';
16
+ import { basename, join, sep } from 'node:path';
17
+ import { debug, isMissing, reportError } from './diagnostics.js';
18
+ /** Entries recorded at most; past it, unknown paths count as changed. */
19
+ const DEFAULT_LIMIT = 100_000;
20
+ /** A folder is its identity (what is in it is compared entry by entry); a file also its content. */
21
+ function stateOf(stats) {
22
+ return stats.isDirectory()
23
+ ? `d:${stats.dev}:${stats.ino}`
24
+ : `f:${stats.dev}:${stats.ino}:${stats.size}:${stats.mtimeMs}`;
25
+ }
26
+ /**
27
+ * The recorded state of the watched paths. `changed(path)` answers whether a reported path really
28
+ * changed since it was recorded (or since its last change) and records its new state.
29
+ */
30
+ export class WatchedState {
31
+ #states = new Map();
32
+ #limit;
33
+ #skip;
34
+ #complete = true;
35
+ #ignored = 0;
36
+ /**
37
+ * @param skip paths that are neither recorded nor compared (files whose events the caller drops,
38
+ * such as views served by Vite).
39
+ */
40
+ constructor(options = {}) {
41
+ this.#limit = options.limit ?? DEFAULT_LIMIT;
42
+ this.#skip = options.skip ?? (() => false);
43
+ }
44
+ /** Whether every path under the recorded folders is known (false past the limit). */
45
+ get complete() {
46
+ return this.#complete;
47
+ }
48
+ /** Events answered "no change" so far. */
49
+ get ignored() {
50
+ return this.#ignored;
51
+ }
52
+ /** Records a file or folder (not its contents); a missing one is recorded as absent. */
53
+ record(path) {
54
+ this.#update(path, this.#read(path));
55
+ }
56
+ /** Records a folder and everything under it (symbolic links are not followed into). */
57
+ recordTree(folder) {
58
+ const started = performance.now();
59
+ this.record(folder);
60
+ this.#walk(folder);
61
+ debug('dev watch: recorded the watched files', {
62
+ folder,
63
+ entries: this.#states.size,
64
+ complete: this.#complete,
65
+ ms: Math.round(performance.now() - started),
66
+ });
67
+ }
68
+ /**
69
+ * Whether the path an fs.watch event on `folder` names changed (see `changed`). The folder's own
70
+ * name that is not an entry of it means the folder itself (libuv).
71
+ */
72
+ changedIn(folder, name) {
73
+ const path = join(folder, name);
74
+ if (name === basename(folder) && this.#read(path) === undefined && !this.#states.has(path))
75
+ return this.changed(folder);
76
+ return this.changed(path);
77
+ }
78
+ /**
79
+ * Whether `path` differs from its recorded state; the new state is recorded. A path that is
80
+ * absent now and was absent (or unknown with a complete record) did not change; an unchanged
81
+ * folder changed when an entry below it did.
82
+ */
83
+ changed(path) {
84
+ const state = this.#read(path);
85
+ // Unreadable (reported): restart, as a watcher without this check would.
86
+ if (state === null)
87
+ return true;
88
+ const before = this.#states.get(path);
89
+ const same = state === undefined ? before === undefined && this.#complete : state === before;
90
+ if (!same) {
91
+ this.#update(path, state);
92
+ // A new or replaced folder: record what is in it, so later events for it compare.
93
+ if (state?.startsWith('d:'))
94
+ this.#walk(path);
95
+ debug('dev watch: path changed', { path, state: state === undefined ? 'absent' : 'present' });
96
+ return true;
97
+ }
98
+ if (state?.startsWith('d:') && this.#walk(path)) {
99
+ debug('dev watch: an entry of the folder changed', { path });
100
+ return true;
101
+ }
102
+ this.#ignored++;
103
+ if (this.#ignored === 1 || this.#ignored % 100 === 0)
104
+ debug('dev watch: event for a path that did not change ignored', {
105
+ path,
106
+ ignored: this.#ignored,
107
+ });
108
+ return false;
109
+ }
110
+ /**
111
+ * Compares and records everything below `folder`; true when an entry is new, deleted or
112
+ * different. Stops early only past the limit (unknown then counts as changed).
113
+ */
114
+ #walk(folder) {
115
+ let changed = false;
116
+ const seen = new Set();
117
+ const pending = [folder];
118
+ while (pending.length) {
119
+ const current = pending.pop();
120
+ let entries;
121
+ try {
122
+ entries = readdirSync(current, { withFileTypes: true });
123
+ }
124
+ catch (error) {
125
+ // A folder removed while it is read is simply absent; anything else leaves it unknown.
126
+ if (!isMissing(error)) {
127
+ this.#complete = false;
128
+ reportError('DEV_WATCH_SCAN_FAILED', `Unable to read ${current} for dokebi dev`, error);
129
+ }
130
+ changed = true;
131
+ continue;
132
+ }
133
+ for (const entry of entries) {
134
+ const path = join(current, entry.name);
135
+ if (this.#skip(path))
136
+ continue;
137
+ seen.add(path);
138
+ const state = this.#read(path);
139
+ if (state !== this.#states.get(path)) {
140
+ changed = true;
141
+ this.#update(path, state);
142
+ }
143
+ if (entry.isDirectory())
144
+ pending.push(path);
145
+ }
146
+ }
147
+ // Recorded entries that are gone.
148
+ const prefix = folder.endsWith(sep) ? folder : `${folder}${sep}`;
149
+ for (const path of this.#states.keys())
150
+ if (path.startsWith(prefix) && !seen.has(path)) {
151
+ this.#states.delete(path);
152
+ changed = true;
153
+ }
154
+ return changed || !this.#complete;
155
+ }
156
+ #update(path, state) {
157
+ if (state === null)
158
+ return;
159
+ if (state === undefined) {
160
+ // A deleted folder takes what was recorded below it.
161
+ if (this.#states.get(path)?.startsWith('d:')) {
162
+ const prefix = `${path}${sep}`;
163
+ for (const key of this.#states.keys())
164
+ if (key.startsWith(prefix))
165
+ this.#states.delete(key);
166
+ }
167
+ this.#states.delete(path);
168
+ return;
169
+ }
170
+ if (!this.#states.has(path) && this.#states.size >= this.#limit) {
171
+ if (this.#complete)
172
+ debug('dev watch: too many files to record; unknown paths count as changed', {
173
+ limit: this.#limit,
174
+ });
175
+ this.#complete = false;
176
+ return;
177
+ }
178
+ this.#states.set(path, state);
179
+ }
180
+ /** The state, undefined when absent, null when it cannot be read (reported). */
181
+ #read(path) {
182
+ try {
183
+ return stateOf(statSync(path));
184
+ }
185
+ catch (error) {
186
+ if (isMissing(error))
187
+ return undefined;
188
+ reportError('DEV_WATCH_STAT_FAILED', `Unable to read ${path} for dokebi dev`, error, 10_000);
189
+ return null;
190
+ }
191
+ }
192
+ }
@@ -1,14 +1,17 @@
1
- /**
2
- * CLI diagnostics on stderr (D-0047). The CLI has no logger: errors it recovers from are printed
3
- * as one line on stderr, and `DOKEBI_LOG_LEVEL=debug` adds debug lines. stdout stays reserved
4
- * for command results (and the --json envelope).
5
- */
6
1
  /**
7
2
  * The full text of a caught value for a log field (D-0047): name, whole message and stack, then
8
- * each `cause` the same way. Only credentials in URLs (`//user:password@`) are removed; a text
9
- * over 16 KB is cut and marked. A hostile accessor still yields the value's type.
3
+ * each `cause` the same way. Only credentials are removed, by core's rule (`maskCredentials`:
4
+ * `//user:password@` of a URL, also with a raw `/` in the password, and `Password=` of a
5
+ * connection string); a text over 16 KB is cut and marked. A hostile accessor still yields the
6
+ * value's type.
10
7
  */
11
8
  export declare function describeError(error: unknown): string;
9
+ /**
10
+ * Every string in a value with credentials masked by core's rule (`maskCredentials`): for what a
11
+ * child process of the project sends back (describe, the user-store probe), whose own masking is
12
+ * a first line of defence only. Other values are kept; arrays and plain objects are copied.
13
+ */
14
+ export declare function maskTexts<T>(value: T, depth?: number): T;
12
15
  /** Errors that mean "the file or folder is not there", which callers treat as absence. */
13
16
  export declare function isMissing(error: unknown): boolean;
14
17
  export declare function debugEnabled(): boolean;
@@ -3,12 +3,15 @@
3
3
  * as one line on stderr, and `DOKEBI_LOG_LEVEL=debug` adds debug lines. stdout stays reserved
4
4
  * for command results (and the --json envelope).
5
5
  */
6
+ import { redaction } from '@dokebi/core/libraries';
6
7
  const reported = new Map();
7
8
  const suppressed = new Map();
8
9
  /**
9
10
  * The full text of a caught value for a log field (D-0047): name, whole message and stack, then
10
- * each `cause` the same way. Only credentials in URLs (`//user:password@`) are removed; a text
11
- * over 16 KB is cut and marked. A hostile accessor still yields the value's type.
11
+ * each `cause` the same way. Only credentials are removed, by core's rule (`maskCredentials`:
12
+ * `//user:password@` of a URL, also with a raw `/` in the password, and `Password=` of a
13
+ * connection string); a text over 16 KB is cut and marked. A hostile accessor still yields the
14
+ * value's type.
12
15
  */
13
16
  export function describeError(error) {
14
17
  const parts = [];
@@ -34,11 +37,25 @@ export function describeError(error) {
34
37
  current = undefined;
35
38
  }
36
39
  }
37
- const text = parts.join('\n').replace(/\/\/[^\s/@]*@/g, '//***@');
40
+ const text = redaction.maskCredentials(parts.join('\n'));
38
41
  return text.length > 16_384
39
42
  ? `${text.slice(0, 16_384)}… [truncated ${text.length - 16_384} characters]`
40
43
  : text;
41
44
  }
45
+ /**
46
+ * Every string in a value with credentials masked by core's rule (`maskCredentials`): for what a
47
+ * child process of the project sends back (describe, the user-store probe), whose own masking is
48
+ * a first line of defence only. Other values are kept; arrays and plain objects are copied.
49
+ */
50
+ export function maskTexts(value, depth = 0) {
51
+ if (typeof value === 'string')
52
+ return redaction.maskCredentials(value);
53
+ if (depth > 32 || value === null || typeof value !== 'object')
54
+ return value;
55
+ if (Array.isArray(value))
56
+ return value.map((item) => maskTexts(item, depth + 1));
57
+ return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, maskTexts(item, depth + 1)]));
58
+ }
42
59
  /** Errors that mean "the file or folder is not there", which callers treat as absence. */
43
60
  export function isMissing(error) {
44
61
  const code = error?.code;
@@ -46,10 +63,11 @@ export function isMissing(error) {
46
63
  }
47
64
  function write(record) {
48
65
  try {
66
+ // dokebi-check: allow console-output the CLI's own diagnostics: it has no app logger (JSON lines on stderr)
49
67
  process.stderr.write(`${JSON.stringify({ time: new Date().toISOString(), ...record })}\n`);
50
68
  }
51
69
  catch {
52
- // stderr itself is unavailable: there is no further channel to report on.
70
+ // dokebi-check: allow empty-catch stderr itself is unavailable: there is no channel left to report on
53
71
  }
54
72
  }
55
73
  export function debugEnabled() {
@@ -0,0 +1,50 @@
1
+ import type { DescribeOptions } from './describe.js';
2
+ /** What one entry says differs. */
3
+ export type DriftKind = 'connection' | 'table' | 'collection' | 'column' | 'column_comment';
4
+ export interface DriftEntry {
5
+ readonly kind: DriftKind;
6
+ /** The dokebi-db connection. */
7
+ readonly connection: string;
8
+ /** `<connection>`, `<table>` (`<schema>.<table>` outside the first schema), `<table>.<column>`. */
9
+ readonly target: string;
10
+ /** What the file says; null: the file has it not (only in the code). */
11
+ readonly expected_value: string | null;
12
+ /** What the database has; null: the database has it not (a missing column, no comment). */
13
+ readonly code_value: string | null;
14
+ }
15
+ export interface DriftResult {
16
+ /** No drift and every connection named in the file could be read. */
17
+ readonly ok: boolean;
18
+ readonly drift: DriftEntry[];
19
+ /** Connections named in the file that could not be read (nothing compared for them). */
20
+ readonly unavailable: {
21
+ readonly connection: string;
22
+ readonly reason: string;
23
+ }[];
24
+ readonly compared: {
25
+ readonly connections: number;
26
+ readonly tables: number;
27
+ readonly columns: number;
28
+ readonly comments: number;
29
+ };
30
+ readonly ms: number;
31
+ }
32
+ type Row = Record<string, unknown>;
33
+ /** Whitespace runs are one space and the ends are trimmed before two comments are compared. */
34
+ export declare const normalComment: (text: string) => string;
35
+ /** The connections of an expected file (a describe result, or its `--json` envelope). */
36
+ export declare function expectedConnections(file: string, document: unknown): Row[];
37
+ /**
38
+ * The drift between expected and actual `db.connections` (pure: no reading, no connecting).
39
+ * Entries come in the order of the expected file, then what only the database has.
40
+ */
41
+ export declare function compareDatabases(expected: readonly Row[], actual: readonly Row[]): Omit<DriftResult, 'ok' | 'ms'>;
42
+ export interface DriftOptions extends Pick<DescribeOptions, 'timeoutMs'> {
43
+ /** The expected file, relative to `root` or absolute. */
44
+ readonly expected: string;
45
+ }
46
+ /** `dokebi drift`: reads the expected file, describes the project with its databases, compares. */
47
+ export declare function drift(root: string, options: DriftOptions): Promise<DriftResult>;
48
+ /** The human output: one line per entry, then a summary line. */
49
+ export declare function driftText(result: DriftResult): string;
50
+ export {};
package/dist/drift.js ADDED
@@ -0,0 +1,218 @@
1
+ // `dokebi drift --expected <file> [--json]` (D-0120, the shape proposed in D-0090 §3 N): what the
2
+ // project's databases have against what a file says they should have. The file has the shape of
3
+ // `dokebi describe --json` (only `db.connections` is read; the whole describe envelope works
4
+ // too), written by hand or by a design tool. The actual side is `describe --with-db`: the
5
+ // project's own `dokebi-migrate schema` per connection (it connects). Only what the file names
6
+ // is compared: a connection, its tables (or collections) when it lists them, a table's columns
7
+ // when it lists them, and a column's comment when it gives one. A database comment is what the
8
+ // migrations wrote (PostgreSQL `COMMENT ON COLUMN … IS '…'`, MySQL and MariaDB `COMMENT '…'` in
9
+ // the column definition); MongoDB has no comments.
10
+ import { readFile } from 'node:fs/promises';
11
+ import { resolve } from 'node:path';
12
+ import { debug } from './diagnostics.js';
13
+ import { CliError } from './errors.js';
14
+ const record = (value) => value !== null && typeof value === 'object' && !Array.isArray(value) ? value : undefined;
15
+ /** Whitespace runs are one space and the ends are trimmed before two comments are compared. */
16
+ export const normalComment = (text) => text.replace(/\s+/g, ' ').trim();
17
+ function invalid(file, path, message) {
18
+ return new CliError('DRIFT_EXPECTED_INVALID', `${file}: ${path} ${message}`, {
19
+ phase: 'arguments',
20
+ details: {
21
+ file,
22
+ path,
23
+ fix: 'write the describe shape: { "db": { "connections": [{ "name": "main", "tables": [{ "name": "posts", "columns": [{ "name": "title", "comment": "Post title" }] }] }] } }',
24
+ },
25
+ });
26
+ }
27
+ /** A list of named objects at `path`, or undefined when the field is absent. */
28
+ function named(file, path, value) {
29
+ if (value === undefined)
30
+ return undefined;
31
+ if (!Array.isArray(value))
32
+ throw invalid(file, path, 'must be a list');
33
+ return value.map((item, index) => {
34
+ const row = record(item);
35
+ if (!row || typeof row.name !== 'string' || !row.name)
36
+ throw invalid(file, `${path}[${index}]`, 'must be an object with a name');
37
+ return row;
38
+ });
39
+ }
40
+ /** The connections of an expected file (a describe result, or its `--json` envelope). */
41
+ export function expectedConnections(file, document) {
42
+ const top = record(document);
43
+ if (!top)
44
+ throw invalid(file, '$', 'must be a JSON object');
45
+ const result = record(top.result) ?? top;
46
+ const db = record(result.db);
47
+ if (!db)
48
+ throw invalid(file, 'db', 'is missing: the file names no database to compare');
49
+ const connections = named(file, 'db.connections', db.connections);
50
+ if (!connections)
51
+ throw invalid(file, 'db.connections', 'is missing');
52
+ connections.forEach((connection, index) => {
53
+ const at = `db.connections[${index}]`;
54
+ const tables = named(file, `${at}.tables`, connection.tables) ?? [];
55
+ named(file, `${at}.collections`, connection.collections);
56
+ tables.forEach((table, position) => {
57
+ const columns = named(file, `${at}.tables[${position}].columns`, table.columns) ?? [];
58
+ columns.forEach((column, place) => {
59
+ const comment = column.comment;
60
+ if (comment !== undefined && comment !== null && typeof comment !== 'string')
61
+ throw invalid(file, `${at}.tables[${position}].columns[${place}].comment`, 'must be a string or null');
62
+ });
63
+ });
64
+ });
65
+ return connections;
66
+ }
67
+ /**
68
+ * The drift between expected and actual `db.connections` (pure: no reading, no connecting).
69
+ * Entries come in the order of the expected file, then what only the database has.
70
+ */
71
+ export function compareDatabases(expected, actual) {
72
+ const drift = [];
73
+ const unavailable = [];
74
+ const compared = { connections: 0, tables: 0, columns: 0, comments: 0 };
75
+ const entry = (kind, connection, target, expectedValue, codeValue) => drift.push({
76
+ kind,
77
+ connection,
78
+ target,
79
+ expected_value: expectedValue,
80
+ code_value: codeValue,
81
+ });
82
+ for (const want of expected) {
83
+ const name = want.name;
84
+ const have = actual.find((item) => item.name === name);
85
+ if (!have) {
86
+ entry('connection', name, name, name, null);
87
+ continue;
88
+ }
89
+ if (typeof have.unavailable === 'string') {
90
+ unavailable.push({ connection: name, reason: have.unavailable });
91
+ continue;
92
+ }
93
+ compared.connections++;
94
+ // A table outside the first schema of the search path is named `<schema>.<table>`.
95
+ const first = Array.isArray(have.schemas) ? have.schemas[0] : undefined;
96
+ const key = (table) => typeof table.schema === 'string' && table.schema !== first
97
+ ? `${table.schema}.${String(table.name)}`
98
+ : String(table.name);
99
+ for (const [kind, field] of [
100
+ ['table', 'tables'],
101
+ ['collection', 'collections'],
102
+ ]) {
103
+ if (!Array.isArray(want[field]))
104
+ continue;
105
+ // The migration history is never compared, on either side: a describe answer used as the
106
+ // expected file lists it too (`isMigrationHistory`).
107
+ const all = Array.isArray(have[field]) ? have[field] : [];
108
+ const history = new Set(all.filter((item) => item.isMigrationHistory === true).map((item) => key(item)));
109
+ const wanted = want[field].filter((item) => item.isMigrationHistory !== true && !history.has(key(item)));
110
+ const held = all.filter((item) => item.isMigrationHistory !== true);
111
+ const heldBy = new Map(held.map((item) => [key(item), item]));
112
+ const wantedKeys = new Set();
113
+ for (const table of wanted) {
114
+ const target = key(table);
115
+ wantedKeys.add(target);
116
+ const found = heldBy.get(target);
117
+ if (!found) {
118
+ entry(kind, name, target, target, null);
119
+ continue;
120
+ }
121
+ compared.tables++;
122
+ if (kind !== 'table' || !Array.isArray(table.columns))
123
+ continue;
124
+ const columns = Array.isArray(found.columns) ? found.columns : [];
125
+ const columnBy = new Map(columns.map((column) => [String(column.name), column]));
126
+ const wantedColumns = new Set();
127
+ for (const column of table.columns) {
128
+ const columnName = String(column.name);
129
+ wantedColumns.add(columnName);
130
+ const at = `${target}.${columnName}`;
131
+ const real = columnBy.get(columnName);
132
+ if (!real) {
133
+ entry('column', name, at, at, null);
134
+ continue;
135
+ }
136
+ compared.columns++;
137
+ // An empty or absent comment in the file means "not decided": not compared.
138
+ const comment = typeof column.comment === 'string' ? column.comment : '';
139
+ if (!normalComment(comment))
140
+ continue;
141
+ compared.comments++;
142
+ const stored = typeof real.comment === 'string' && real.comment ? real.comment : null;
143
+ if (normalComment(comment) !== normalComment(stored ?? ''))
144
+ entry('column_comment', name, at, comment, stored);
145
+ }
146
+ for (const column of columns)
147
+ if (!wantedColumns.has(String(column.name))) {
148
+ const at = `${target}.${String(column.name)}`;
149
+ entry('column', name, at, null, at);
150
+ }
151
+ }
152
+ for (const [target] of heldBy)
153
+ if (!wantedKeys.has(target))
154
+ entry(kind, name, target, null, target);
155
+ }
156
+ }
157
+ return { drift, unavailable, compared };
158
+ }
159
+ /** `dokebi drift`: reads the expected file, describes the project with its databases, compares. */
160
+ export async function drift(root, options) {
161
+ const started = Date.now();
162
+ const file = resolve(root, options.expected);
163
+ let text;
164
+ try {
165
+ text = await readFile(file, 'utf8');
166
+ }
167
+ catch (error) {
168
+ throw new CliError('DRIFT_EXPECTED_INVALID', `Cannot read the expected file ${file}`, {
169
+ cause: error,
170
+ phase: 'arguments',
171
+ details: { file, fix: 'pass --expected <file> with a JSON file of the describe shape' },
172
+ });
173
+ }
174
+ let document;
175
+ try {
176
+ document = JSON.parse(text);
177
+ }
178
+ catch (error) {
179
+ throw new CliError('DRIFT_EXPECTED_INVALID', `${file} is not JSON`, {
180
+ cause: error,
181
+ phase: 'arguments',
182
+ details: { file, fix: 'write the expected file as JSON (the shape of describe --json)' },
183
+ });
184
+ }
185
+ const expected = expectedConnections(file, document);
186
+ const { describeProject } = await import('./describe.js');
187
+ const described = await describeProject(root, {
188
+ ...(options.timeoutMs === undefined ? {} : { timeoutMs: options.timeoutMs }),
189
+ withDb: true,
190
+ });
191
+ const actual = Array.isArray(described.db.connections) ? described.db.connections : [];
192
+ const result = compareDatabases(expected, actual);
193
+ debug('drift: compared', {
194
+ drift: result.drift.length,
195
+ unavailable: result.unavailable.length,
196
+ ...result.compared,
197
+ });
198
+ return {
199
+ ok: result.drift.length === 0 && result.unavailable.length === 0,
200
+ ...result,
201
+ ms: Date.now() - started,
202
+ };
203
+ }
204
+ /** The human output: one line per entry, then a summary line. */
205
+ export function driftText(result) {
206
+ const value = (text) => (text === null ? 'none' : JSON.stringify(text));
207
+ const lines = result.drift.map((item) => item.kind === 'column_comment'
208
+ ? `${item.connection} ${item.target} column_comment expected ${value(item.expected_value)}, database ${value(item.code_value)}`
209
+ : `${item.connection} ${item.target} ${item.kind} ${item.code_value === null ? 'only in the expected file' : 'only in the database'}`);
210
+ for (const item of result.unavailable)
211
+ lines.push(`${item.connection} unavailable ${item.reason}`);
212
+ const { connections, tables, columns, comments } = result.compared;
213
+ const counts = `${connections} connections, ${tables} tables, ${columns} columns, ${comments} comments compared (${result.ms} ms)`;
214
+ lines.push(result.ok
215
+ ? `drift none: ${counts}`
216
+ : `drift FOUND: ${result.drift.length} difference${result.drift.length === 1 ? '' : 's'}${result.unavailable.length ? `, ${result.unavailable.length} unavailable` : ''}; ${counts}`);
217
+ return `${lines.join('\n')}\n`;
218
+ }
@@ -6,15 +6,39 @@ export declare const ENV_FILE = ".env";
6
6
  * names the line only, never its content.
7
7
  */
8
8
  export declare function checkEnvText(text: string): void;
9
+ /**
10
+ * The variables of a .env text, checked line by line first (ENV_FILE_INVALID). R4CA-11: a byte
11
+ * order mark (editors on Windows write one) is no part of the first name; util.parseEnv kept it
12
+ * there (`\uFEFFDATABASE_URL`), so the app missed that variable and an install that looked for
13
+ * it appended a second line that won over the user's value.
14
+ */
15
+ export declare function parseEnvText(text: string): Record<string, string>;
9
16
  /**
10
17
  * The variables of `<root>/.env` (empty when there is none). Throws ENV_FILE_INVALID when the
11
18
  * file cannot be read or a line is not `KEY=value`.
12
19
  */
13
20
  export declare function readEnvFile(root: string): Record<string, string>;
21
+ /**
22
+ * Variables .env never passes to the app (R4CA-3, user decision 2026-10-26): DOKEBI_CONFIG chooses
23
+ * the settings file, and every CLI command and `npm start` read it from the environment only;
24
+ * from .env it would make only the app the CLI runs read another file than all of them.
25
+ */
26
+ export declare const ENVIRONMENT_ONLY: readonly string[];
27
+ /** A line of .env the CLI does not pass on (ENV_DOKEBI_CONFIG_IGNORED); never its value. */
28
+ export interface EnvFileWarning {
29
+ readonly code: 'ENV_DOKEBI_CONFIG_IGNORED';
30
+ /** `.env:<line>` */
31
+ readonly path: string;
32
+ readonly variable: string;
33
+ readonly hint: string;
34
+ }
35
+ /** What `.env` sets that the app does not get (ENVIRONMENT_ONLY), by line; [] without .env. */
36
+ export declare function envFileWarnings(root: string): EnvFileWarning[];
14
37
  /**
15
38
  * The environment of an app the CLI runs: the project's .env, then the CLI's own environment on
16
39
  * 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).
40
+ * variables such as DOKEBI_DEV or the verify and instance tokens). ENVIRONMENT_ONLY variables are
41
+ * never taken from .env (envFileWarnings names their lines).
18
42
  */
19
43
  export declare function appEnvironment(root: string, mode: string, extra?: Readonly<Record<string, string>>, fileValues?: Readonly<Record<string, string>>): NodeJS.ProcessEnv;
20
44
  /** Whether a variable is set in the environment or in the project's .env (values not returned). */