@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.
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 +527 -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 +29 -3
  110. package/dist/install-module.js +142 -13
  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 +106 -27
  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 +59 -4
  124. package/dist/npm-client.js +220 -16
  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 +206 -20
  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 +3016 -336
  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 +50 -4
  170. package/dist/server.js +790 -103
  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 +67 -0
  180. package/dist/transaction.js +428 -46
  181. package/dist/verify-tests.d.ts +98 -0
  182. package/dist/verify-tests.js +449 -0
  183. package/dist/verify.d.ts +113 -4
  184. package/dist/verify.js +326 -28
  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
@@ -1,13 +1,190 @@
1
- import { lstat, mkdir, mkdtemp, open, readdir, readFile, rename, rm, rmdir, writeFile, } from 'node:fs/promises';
2
- import { dirname, join } from 'node:path';
1
+ import { link, lstat, mkdir, mkdtemp, open, readdir, readFile, rename, rm, rmdir, unlink, writeFile, } from 'node:fs/promises';
2
+ import { basename, dirname, join, relative } from 'node:path';
3
+ import { hash as hashing } from '@dokebi/core/libraries';
3
4
  import { debug, debugEnabled, describeError, reportError } from './diagnostics.js';
4
5
  import { CliError } from './errors.js';
5
- import { exists, fingerprint, json, securePath } from './filesystem.js';
6
+ import { exists, fingerprint, hash, json, securePath } from './filesystem.js';
7
+ import { lockDiagnostic, lockHolder } from './project-inspector.js';
8
+ /**
9
+ * What is at `path` now, without following a symlink: null when nothing is there, `file:<sha256>`
10
+ * for a file, `directory:<dev>:<ino>` for a folder (its identity: a folder's content is not
11
+ * hashed), `symlink` or `other`.
12
+ */
13
+ async function observe(path) {
14
+ let stat;
15
+ try {
16
+ stat = await lstat(path);
17
+ }
18
+ catch (error) {
19
+ if (error.code === 'ENOENT')
20
+ return null;
21
+ throw error;
22
+ }
23
+ if (stat.isSymbolicLink())
24
+ return 'symlink';
25
+ if (stat.isDirectory())
26
+ return `directory:${stat.dev}:${stat.ino}`;
27
+ if (!stat.isFile())
28
+ return 'other';
29
+ return `file:${await hashing.sha256File(path)}`;
30
+ }
31
+ const LINK_UNSUPPORTED = new Set(['EPERM', 'ENOTSUP', 'EOPNOTSUPP', 'EMLINK', 'EXDEV']);
32
+ /** Why `now` is not the `installed` fingerprint (both as `observe` writes them). */
33
+ function differs(installed, now) {
34
+ if (now === 'symlink')
35
+ return 'symlink';
36
+ return installed?.startsWith('file:') && now.startsWith('file:') ? 'modified' : 'replaced';
37
+ }
38
+ /**
39
+ * DUPLICATE_CHANGE (D-0116): two parts of one change wrote the same path (two plugins with the
40
+ * same target, a file where a folder goes). A defect of the packages or the CLI, never of the user.
41
+ */
42
+ function duplicateChange(path) {
43
+ return new CliError('DUPLICATE_CHANGE', `${path} would be written twice by this change; nothing was written`, {
44
+ details: {
45
+ path,
46
+ fix: 'two packages of this change claim the same path: install them one at a time to see which, and report it to their authors',
47
+ },
48
+ phase: 'plan',
49
+ });
50
+ }
51
+ /**
52
+ * INTERRUPTED: a termination signal (Ctrl+C, SIGTERM, SIGHUP) came before the commit, so the
53
+ * change was not applied; the lock and the staging folder are removed as after any refusal.
54
+ */
55
+ export function interruptedError(signal, cause) {
56
+ return new CliError('INTERRUPTED', `${signal} received: the change was cancelled before it was applied`, {
57
+ ...(cause === undefined ? {} : { cause }),
58
+ details: {
59
+ signal,
60
+ fix: 'nothing was written; run the command again when you want the change',
61
+ },
62
+ phase: 'plan',
63
+ });
64
+ }
65
+ const TERMINATION = ['SIGINT', 'SIGTERM', 'SIGHUP'];
66
+ /** The open changes of this process; while there is one, a signal cancels instead of killing. */
67
+ const active = new Set();
68
+ const cancellers = new Map();
69
+ function releaseSignals() {
70
+ for (const [signal, handler] of cancellers)
71
+ process.off(signal, handler);
72
+ cancellers.clear();
73
+ }
74
+ function unwatchSignals(item) {
75
+ active.delete(item);
76
+ if (!active.size)
77
+ releaseSignals();
78
+ }
79
+ /**
80
+ * The first termination signal while a change is open cancels it: a child npm gets the signal
81
+ * from its own forwarder (process-group.ts) and fails, a commit not yet begun throws INTERRUPTED,
82
+ * and the command ends through its usual cleanup, which removes .dokebi/lock and the staging
83
+ * folder. Killed by the default action instead, the process left both behind, and every later
84
+ * command was PROJECT_LOCKED until a person looked, although nothing had been applied. A commit
85
+ * that has begun runs to its end (or its rollback). A second signal ends the process at once.
86
+ */
87
+ function watchSignals(change) {
88
+ active.add(change);
89
+ if (cancellers.size)
90
+ return;
91
+ for (const signal of TERMINATION) {
92
+ const handler = () => {
93
+ const again = [...active].some((item) => item.interrupted !== undefined);
94
+ if (again || !active.size) {
95
+ debug('Second termination signal: ending at once', { signal });
96
+ releaseSignals();
97
+ if (process.listenerCount(signal) === 0)
98
+ process.kill(process.pid, signal);
99
+ return;
100
+ }
101
+ // dokebi-check: allow console-output the user's terminal: what the first Ctrl+C does
102
+ process.stderr.write(`dokebi: ${signal} received, cancelling the change (nothing is applied); send it again to stop at once\n`);
103
+ for (const item of active)
104
+ item.cancel(signal);
105
+ };
106
+ cancellers.set(signal, handler);
107
+ process.on(signal, handler);
108
+ }
109
+ }
110
+ /**
111
+ * A change without a transaction that a termination signal cancels like one (R4CB-3: `dokebi
112
+ * init`, whose staging folder a killed CLI left in the target folder, so the next init was
113
+ * PROJECT_EXISTS). Open it before the work, `check()` before the step that makes the change
114
+ * visible, `failure(error)` for what the work threw, and `close()` in a finally.
115
+ */
116
+ export class Interruption {
117
+ #signal;
118
+ #closed = false;
119
+ constructor() { }
120
+ static open() {
121
+ const interruption = new Interruption();
122
+ watchSignals(interruption);
123
+ return interruption;
124
+ }
125
+ get interrupted() {
126
+ return this.#signal;
127
+ }
128
+ cancel(signal) {
129
+ this.#signal ??= signal;
130
+ debug('Change cancelled by a termination signal', { signal });
131
+ }
132
+ /** Throws INTERRUPTED when a signal came. */
133
+ check() {
134
+ if (this.#signal)
135
+ throw interruptedError(this.#signal);
136
+ }
137
+ /** INTERRUPTED (with `error` as its cause) when a signal came, else `error` itself. */
138
+ failure(error) {
139
+ if (!this.#signal || (error instanceof CliError && error.code === 'INTERRUPTED'))
140
+ return error;
141
+ return interruptedError(this.#signal, error);
142
+ }
143
+ close() {
144
+ if (this.#closed)
145
+ return;
146
+ this.#closed = true;
147
+ unwatchSignals(this);
148
+ }
149
+ }
6
150
  export class Transaction {
7
151
  root;
8
152
  directory;
9
153
  #snapshots = new Map();
154
+ #interrupted;
155
+ #committing = false;
156
+ /** The termination signal that cancelled this change, if one came. */
157
+ get interrupted() {
158
+ return this.#interrupted;
159
+ }
160
+ /** Marks the change cancelled (watchSignals); a commit that has begun is not affected. */
161
+ cancel(signal) {
162
+ this.#interrupted ??= signal;
163
+ debug('Change cancelled by a termination signal', { signal, committing: this.#committing });
164
+ }
165
+ /**
166
+ * What a command that owns this transaction throws for `error` (R4CB-4): INTERRUPTED when a
167
+ * signal cancelled the change before its commit began, whatever part of it then failed (an npm
168
+ * run other than the staging install, an install module the terminal's Ctrl+C reached too), so
169
+ * the result says nothing was applied (phase plan, mutation none). From the commit on, the
170
+ * error stays as it is (COMMIT_FAILED, RECOVERY_REQUIRED, ...).
171
+ */
172
+ failure(error) {
173
+ if (!this.#interrupted || this.#committing)
174
+ return error;
175
+ if (error instanceof CliError && error.code === 'INTERRUPTED')
176
+ return error;
177
+ return interruptedError(this.#interrupted, error);
178
+ }
10
179
  #changes = [];
180
+ /**
181
+ * Paths written with the content they already have: nothing is written there (S-a), but they
182
+ * stay claimed (a second write is DUPLICATE_CHANGE) and watched (an edit before the commit is
183
+ * CONCURRENT_CHANGE), as a real change is (R7).
184
+ */
185
+ #unchanged = new Set();
186
+ /** Staging files are numbered when a write starts, so writes in flight never share one. */
187
+ #staged = 0;
11
188
  #keep = false;
12
189
  constructor(root, directory) {
13
190
  this.root = root;
@@ -22,7 +199,25 @@ export class Transaction {
22
199
  handle = await open(lock, 'wx');
23
200
  }
24
201
  catch (cause) {
25
- throw new CliError('PROJECT_LOCKED', 'Another operation or interrupted transaction owns .dokebi/lock', { cause });
202
+ if (cause.code !== 'EEXIST')
203
+ throw new CliError('LOCK_FAILED', 'Cannot create .dokebi/lock', {
204
+ cause,
205
+ details: {
206
+ error: cause.code ?? describeError(cause),
207
+ fix: 'the project folder (.dokebi) must be writable by this user',
208
+ },
209
+ phase: 'unknown',
210
+ });
211
+ // Who holds it and what to do, as doctor says it (AI-M04 F6): the error had no details.
212
+ const holder = await lockHolder(root);
213
+ const { hint, details } = lockDiagnostic(holder ?? { pid: null, running: null, since: null }, []);
214
+ throw new CliError('PROJECT_LOCKED', 'Another operation or interrupted transaction owns .dokebi/lock', {
215
+ cause,
216
+ details: {
217
+ ...details,
218
+ fix: hint,
219
+ },
220
+ });
26
221
  }
27
222
  try {
28
223
  await handle.writeFile(json({ pid: process.pid }));
@@ -30,7 +225,9 @@ export class Transaction {
30
225
  if ((await readdir(control)).some((name) => name.startsWith('transaction-'))) {
31
226
  throw new CliError('RECOVERY_REQUIRED', 'Interrupted transaction found; preserve .dokebi/transaction-* for recovery', { phase: 'unknown' });
32
227
  }
33
- return new Transaction(root, await mkdtemp(join(control, 'transaction-')));
228
+ const transaction = new Transaction(root, await mkdtemp(join(control, 'transaction-')));
229
+ watchSignals(transaction);
230
+ return transaction;
34
231
  }
35
232
  catch (cause) {
36
233
  await handle.close().catch((error) => {
@@ -52,7 +249,7 @@ export class Transaction {
52
249
  const planned = this.#changes.find((change) => change.path === current);
53
250
  if (planned) {
54
251
  if (planned.kind !== 'directory')
55
- throw new CliError('DUPLICATE_CHANGE', current);
252
+ throw duplicateChange(current);
56
253
  continue;
57
254
  }
58
255
  await this.capture(current);
@@ -60,23 +257,74 @@ export class Transaction {
60
257
  if (expected === null)
61
258
  this.#changes.push({ path: current, kind: 'directory' });
62
259
  else if (!expected?.startsWith('directory:'))
63
- throw new CliError('PATH_CONFLICT', current);
260
+ throw new CliError('PATH_CONFLICT', `${current} is not a folder, but the change needs a folder there for ${path}`, {
261
+ details: {
262
+ path: current,
263
+ needed: path,
264
+ fix: `move ${current} away (a file or link is in the way), then repeat this command`,
265
+ },
266
+ phase: 'plan',
267
+ });
268
+ }
269
+ }
270
+ /** The current text of `path` in the project, or undefined when there is none. */
271
+ async #text(path) {
272
+ try {
273
+ return await readFile(await securePath(this.root, path), 'utf8');
274
+ }
275
+ catch (error) {
276
+ if (error.code === 'ENOENT')
277
+ return undefined;
278
+ throw error;
64
279
  }
65
280
  }
66
281
  async read(path) {
67
282
  await this.capture(path);
68
283
  return readFile(await securePath(this.root, path), 'utf8');
69
284
  }
285
+ /**
286
+ * Rewrites text before it is staged (D-0154: the reloadable marks of dokebi.yaml and
287
+ * dokebi.example.yaml). Set by the operation that owns the transaction.
288
+ */
289
+ transformText;
70
290
  async write(path, content) {
71
- const source = join(this.directory, `new-${this.#changes.length}`);
291
+ if (typeof content === 'string' && this.transformText) {
292
+ const transformed = await this.transformText(path, content);
293
+ // An edit that only dropped marks the file already has (a section rewritten without its
294
+ // schema at hand) is no change: the file stays as it is and is not reported as changed.
295
+ content = transformed;
296
+ }
297
+ // S-a: the content the file already has is no change: never rewritten (a rewrite with the
298
+ // same text gave src/dokebi.generated.ts a new time, and dokebi dev restarted the app).
299
+ if (typeof content === 'string' &&
300
+ !this.#claimed(path) &&
301
+ (await this.#text(path)) === content) {
302
+ await this.capture(path);
303
+ if (this.#claimed(path))
304
+ throw duplicateChange(path);
305
+ this.#unchanged.add(path);
306
+ return;
307
+ }
308
+ const source = join(this.directory, `new-${this.#staged++}`);
72
309
  await writeFile(source, content, { mode: 0o600 });
73
- await this.replace(path, source);
310
+ await this.#add(path, source, `file:${hash(content)}`);
74
311
  }
312
+ /** Puts `source` (a file or folder) at `path` on commit; without `source`, removes `path`. */
75
313
  async replace(path, source) {
76
- if (this.#changes.some((change) => change.path === path))
77
- throw new CliError('DUPLICATE_CHANGE', path);
314
+ await this.#add(path, source);
315
+ }
316
+ /** A change of this transaction already claims `path` (a write, a removal, an unchanged write). */
317
+ #claimed(path) {
318
+ return this.#unchanged.has(path) || this.#changes.some((change) => change.path === path);
319
+ }
320
+ async #add(path, source, installs) {
321
+ if (this.#claimed(path))
322
+ throw duplicateChange(path);
78
323
  await this.capture(path);
79
- this.#changes.push(source === undefined ? { path } : { path, source });
324
+ // Again after the await: a change of the same path may have been added meanwhile.
325
+ if (this.#claimed(path))
326
+ throw duplicateChange(path);
327
+ this.#changes.push(source === undefined ? { path } : installs ? { path, source, installs } : { path, source });
80
328
  }
81
329
  get paths() {
82
330
  return this.#changes.map((change) => change.path);
@@ -84,20 +332,52 @@ export class Transaction {
84
332
  /**
85
333
  * Applies every change; `afterApply` runs once they are all in place, and when it fails the
86
334
  * changes are rolled back like a failed apply (D-0035: the workspace npm install).
335
+ *
336
+ * The rollback only undoes what is still this commit's own (ISSUE-0006 A06): `afterApply` can
337
+ * run for minutes while the user edits files. A path that is no longer what this commit put
338
+ * there (or, for a removed path, where something appeared) is left as it is, its original stays
339
+ * in the transaction folder, and the commit ends with RECOVERY_REQUIRED naming those paths.
87
340
  */
88
341
  async commit(beforeApply, afterApply) {
342
+ if (this.#interrupted)
343
+ throw interruptedError(this.#interrupted);
344
+ this.#committing = true;
89
345
  for (const [path, expected] of this.#snapshots) {
90
346
  if ((await fingerprint(await securePath(this.root, path))) !== expected) {
91
347
  throw new CliError('CONCURRENT_CHANGE', `File changed while planning: ${path}`);
92
348
  }
93
349
  }
94
- const journal = this.#changes.map((change, index) => ({
95
- ...change,
96
- backup: join(this.directory, `old-${index}`),
97
- existed: this.#snapshots.get(change.path) !== null,
98
- backedUp: false,
99
- installed: false,
100
- }));
350
+ // A staged folder is recognised by its dev:ino, and a file system hands a freed inode number
351
+ // out again at once (Linux: nearly every time): a node_modules the user removes and creates
352
+ // while afterApply runs would look like ours, and the rollback would remove the user's folder.
353
+ // A handle open on each staged folder until the commit ends keeps its inode in use, so no new
354
+ // folder can get the same number (not on Windows, where a folder cannot be opened).
355
+ const held = [];
356
+ try {
357
+ await this.#apply(held, beforeApply, afterApply);
358
+ }
359
+ finally {
360
+ await Promise.all(held.map((handle) => handle.close()));
361
+ }
362
+ }
363
+ /** The journal, the changes in order and `afterApply`; a failure rolls back (commit). */
364
+ async #apply(held, beforeApply, afterApply) {
365
+ const journal = [];
366
+ for (const [index, change] of this.#changes.entries()) {
367
+ // What this change installs, to recognise it again in a rollback: a written file's hash, a
368
+ // staged file's hash or a staged folder's identity (node_modules).
369
+ const installs = change.installs ?? (change.source ? await observe(change.source) : null) ?? undefined;
370
+ if (change.source && installs?.startsWith('directory:') && process.platform !== 'win32')
371
+ held.push(await open(change.source, 'r'));
372
+ journal.push({
373
+ ...change,
374
+ ...(installs ? { installs } : {}),
375
+ backup: join(this.directory, `old-${index}`),
376
+ existed: this.#snapshots.get(change.path) !== null,
377
+ backedUp: false,
378
+ installed: false,
379
+ });
380
+ }
101
381
  await writeFile(join(this.directory, 'journal.json'), json(journal));
102
382
  try {
103
383
  for (const [index, change] of journal.entries()) {
@@ -134,41 +414,143 @@ export class Transaction {
134
414
  changes: journal.length,
135
415
  error: describeError(cause),
136
416
  });
137
- const failures = [];
138
- for (const change of [...journal].reverse()) {
417
+ await this.#rollback(journal, cause);
418
+ }
419
+ }
420
+ async #rollback(journal, cause) {
421
+ const failures = [];
422
+ const conflicts = [];
423
+ for (const [index, change] of [...journal.entries()].reverse()) {
424
+ try {
425
+ const conflict = await this.#undo(change, index);
426
+ if (conflict) {
427
+ debug('Rollback kept a path changed during the commit', { ...conflict });
428
+ conflicts.push(conflict);
429
+ }
430
+ }
431
+ catch (error) {
432
+ failures.push(error);
433
+ }
434
+ }
435
+ if (failures.length || conflicts.length) {
436
+ this.#keep = true;
437
+ const directory = relative(this.root, this.directory);
438
+ const kept = conflicts.length
439
+ ? `kept ${conflicts.length} path(s) changed during the operation: ${conflicts
440
+ .map((item) => `${item.path} (${item.reason})`)
441
+ .join(', ')}; `
442
+ : '';
443
+ const failed = failures.length ? `${failures.length} step(s) failed; ` : '';
444
+ throw new CliError('RECOVERY_REQUIRED', `Rollback ${kept}${failed}preserve ${directory} and .dokebi/lock`, {
445
+ cause: failures.length ? new AggregateError([cause, ...failures]) : cause,
446
+ phase: 'commit',
447
+ details: { conflicts, transaction: directory },
448
+ });
449
+ }
450
+ throw new CliError('COMMIT_FAILED', 'Changes were rolled back', { cause });
451
+ }
452
+ /**
453
+ * Undoes one change unless the path is no longer this commit's own: then leaves it and says
454
+ * why. The look and the removal cannot be one atomic step; the window is kept to a rename
455
+ * (see `#undo`'s move-aside) and documented in docs/cli/README.md.
456
+ */
457
+ async #undo(change, index) {
458
+ const backup = change.backedUp ? { backup: relative(this.root, change.backup) } : {};
459
+ let target;
460
+ try {
461
+ // The parent folders, never following a symlink; the path itself is looked at below.
462
+ const parent = dirname(change.path);
463
+ const folder = parent === '.' ? this.root : await securePath(this.root, parent);
464
+ target = join(folder, basename(change.path));
465
+ }
466
+ catch (error) {
467
+ const code = error instanceof CliError ? error.code : undefined;
468
+ if (code !== 'UNSAFE_PATH' && code !== 'PATH_CONFLICT')
469
+ throw error;
470
+ return {
471
+ path: change.path,
472
+ reason: code === 'UNSAFE_PATH' ? 'unsafe-path' : 'path-conflict',
473
+ ...backup,
474
+ };
475
+ }
476
+ if (change.kind === 'directory') {
477
+ if (!change.installed)
478
+ return undefined;
479
+ try {
480
+ // rmdir removes only an empty folder and never follows a symlink.
481
+ await rmdir(target);
482
+ }
483
+ catch (error) {
484
+ const code = error.code;
485
+ if (code === 'ENOENT')
486
+ return undefined;
487
+ if (code === 'ENOTEMPTY' || code === 'EEXIST')
488
+ return { path: change.path, reason: 'not-empty' };
489
+ if (code === 'ENOTDIR')
490
+ return { path: change.path, reason: 'replaced' };
491
+ throw error;
492
+ }
493
+ return undefined;
494
+ }
495
+ if (change.installed) {
496
+ const now = await observe(target);
497
+ if (now !== null) {
498
+ if (now !== change.installs)
499
+ return { path: change.path, reason: differs(change.installs, now), ...backup };
500
+ // Move it aside (atomic), then look again: an edit between the look and the move is
501
+ // found here and put back, never deleted.
502
+ const aside = join(this.directory, `rollback-${index}`);
139
503
  try {
140
- const target = await securePath(this.root, change.path);
141
- if (change.kind === 'directory') {
142
- if (change.installed) {
143
- try {
144
- await rmdir(target);
145
- }
146
- catch (error) {
147
- if (error.code !== 'ENOENT')
148
- throw error;
149
- }
150
- }
151
- continue;
152
- }
153
- if (change.installed)
154
- await rm(target, { recursive: true, force: true });
155
- if (change.backedUp)
156
- await rename(change.backup, target);
504
+ await rename(target, aside);
157
505
  }
158
506
  catch (error) {
159
- failures.push(error);
507
+ if (error.code !== 'ENOENT')
508
+ throw error;
160
509
  }
510
+ const moved = await observe(aside);
511
+ if (moved !== null && moved !== change.installs) {
512
+ const restored = await this.#place(aside, target);
513
+ return {
514
+ path: change.path,
515
+ reason: differs(change.installs, moved),
516
+ ...backup,
517
+ ...(restored ? {} : { kept: relative(this.root, aside) }),
518
+ };
519
+ }
520
+ if (moved !== null)
521
+ await rm(aside, { recursive: true, force: true });
161
522
  }
162
- if (failures.length) {
163
- this.#keep = true;
164
- throw new CliError('RECOVERY_REQUIRED', `Rollback failed; preserve ${this.directory} and .dokebi/lock`, {
165
- cause: new AggregateError([cause, ...failures]),
166
- });
167
- }
168
- throw new CliError('COMMIT_FAILED', 'Changes were rolled back', { cause });
169
523
  }
524
+ if (change.backedUp && !(await this.#place(change.backup, target)))
525
+ return { path: change.path, reason: 'created', ...backup };
526
+ return undefined;
527
+ }
528
+ /**
529
+ * Moves `from` to `to` without replacing anything at `to`: false when something is there. A
530
+ * file goes by link + unlink (link fails on an existing name); a folder (or a file system
531
+ * without hard links) by rename after checking `to` is free, which leaves a tiny window.
532
+ */
533
+ async #place(from, to) {
534
+ try {
535
+ await link(from, to);
536
+ await unlink(from);
537
+ return true;
538
+ }
539
+ catch (error) {
540
+ const code = error.code ?? '';
541
+ if (code === 'EEXIST')
542
+ return false;
543
+ if (!LINK_UNSUPPORTED.has(code))
544
+ throw error;
545
+ debug('Rollback restores by rename (no hard link)', { path: to, code });
546
+ }
547
+ if ((await observe(to)) !== null)
548
+ return false;
549
+ await rename(from, to);
550
+ return true;
170
551
  }
171
552
  async close() {
553
+ unwatchSignals(this);
172
554
  if (this.#keep)
173
555
  return;
174
556
  if (await exists(this.directory)) {
@@ -0,0 +1,98 @@
1
+ /** Default time limit of the test step (`--test-timeout-ms`), like typecheck and build. */
2
+ export declare const TEST_TIMEOUT_MS = 300000;
3
+ /** At most this many results are listed; failures always are (`omitted` counts the rest). */
4
+ export declare const MAX_TEST_RESULTS = 1000;
5
+ /** A leading `[ABC-12] ` tag of a test name: the id (D-0077 A; the framework gives it no meaning). */
6
+ export declare const TEST_ID: RegExp;
7
+ /** One test of the project (a leaf test, or a suite or parent that failed by itself). */
8
+ export interface TestCaseResult {
9
+ /** The test's name after the names of its suites and parent tests (` > `). */
10
+ readonly name: string;
11
+ /** The `[ID]` tag at the start of its name (else of the nearest suite or parent with one). */
12
+ readonly id?: string;
13
+ readonly ok: boolean;
14
+ readonly ms: number;
15
+ /** The test file relative to the project (`/` separators). */
16
+ readonly file?: string;
17
+ readonly skipped?: true;
18
+ readonly todo?: true;
19
+ /** First line of the failure message, secrets masked. */
20
+ readonly error?: string;
21
+ }
22
+ /** `tests` of the verify result. */
23
+ export type TestReport = {
24
+ readonly runner: 'node';
25
+ readonly total: number;
26
+ readonly passed: number;
27
+ readonly failed: number;
28
+ readonly skipped: number;
29
+ readonly todo: number;
30
+ readonly ms: number;
31
+ readonly exitCode: number | null;
32
+ readonly results: TestCaseResult[];
33
+ /** Passed or skipped tests left out of `results` beyond MAX_TEST_RESULTS. */
34
+ readonly omitted?: number;
35
+ } | {
36
+ readonly runner: 'other';
37
+ readonly ms: number;
38
+ readonly exitCode: number | null;
39
+ /** Why the tests were not run with the per-test reporter. */
40
+ readonly reason: string;
41
+ };
42
+ /** How the test script is run. */
43
+ export type TestPlan = {
44
+ readonly runner: 'node';
45
+ /** The node arguments of the script (reporter flags removed). */
46
+ readonly args: string[];
47
+ /** `KEY=value` words before `node`. */
48
+ readonly env: Record<string, string>;
49
+ } | {
50
+ readonly runner: 'other';
51
+ readonly reason: string;
52
+ };
53
+ /**
54
+ * The words of a script, or undefined when it uses anything a shell would interpret (operators,
55
+ * substitutions, escapes, globs outside quotes are kept as words: node expands them itself).
56
+ */
57
+ export declare function scriptWords(script: string): string[] | undefined;
58
+ /** Whether verify can run `script` with its reporter, and how. */
59
+ export declare function planTests(script: string, scripts: Readonly<Record<string, unknown>>): TestPlan;
60
+ /** The id of a test: the tag of its own name, else of the nearest parent with one. */
61
+ export declare function testId(name: string, parents?: readonly string[]): string | undefined;
62
+ /**
63
+ * The credentials core's `maskCredentials` hides in a text (`//user:pa/ss@` of a URL, also with a
64
+ * raw `/` in the password; `Password=...` of a connection string): the text itself and the hidden
65
+ * part, read off its masked form, so verify hides exactly what the logs hide (no rule of its own).
66
+ */
67
+ export declare function maskedCredentials(text: string): string[];
68
+ /**
69
+ * Every secret value verify can know for this project: the values of .env, of environment
70
+ * variables named like a secret (core's `redaction.isSecretEnvironmentName`: the field rule of the
71
+ * logs plus `*_KEY`, `*_PASS`; never PWD or OLDPWD, which would cut the project folder out of
72
+ * every error line), and of the options of the settings file (DOKEBI_CONFIG, else dokebi.yaml) `config show` hides. Throws when they cannot be
73
+ * read (the caller then drops the error lines).
74
+ */
75
+ export declare function knownSecrets(root: string): Promise<string[]>;
76
+ /** An error line with the known secret values and secret-looking parts replaced by `***`. */
77
+ export declare function maskLine(line: string, secrets: readonly string[]): string;
78
+ /** What the test step reports: the check fields and the `tests` of the verify result. */
79
+ export interface TestStep {
80
+ readonly ok: boolean;
81
+ readonly skipped?: string;
82
+ readonly code?: string;
83
+ readonly message?: string;
84
+ readonly details?: unknown;
85
+ readonly tests?: TestReport;
86
+ }
87
+ /**
88
+ * The test step of verify for the project at `root` whose package.json scripts are `scripts`.
89
+ * Throws a CliError (ENV_FILE_INVALID, TEST_FAILED) only when the tests cannot be run at all.
90
+ */
91
+ export declare function runTests(root: string, scripts: Readonly<Record<string, unknown>>, options?: {
92
+ readonly timeoutMs?: number;
93
+ }): Promise<TestStep>;
94
+ /**
95
+ * The human lines verify prints under its test check: one per failing test, then a summary
96
+ * (each line starts with a newline and two spaces).
97
+ */
98
+ export declare function testLines(report: TestReport): string;