@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.
- package/AI.md +279 -57
- package/README.md +35 -10
- package/catalog.json +2520 -258
- package/describe.schema.json +748 -0
- package/dist/api-app.d.ts +2 -0
- package/dist/api-app.js +35 -0
- package/dist/app-urls.js +3 -2
- package/dist/bin.js +638 -37
- package/dist/capability-guide.d.ts +30 -0
- package/dist/capability-guide.js +122 -0
- package/dist/catalog.d.ts +16 -2
- package/dist/catalog.js +19 -7
- package/dist/check/asset-rules.d.ts +6 -0
- package/dist/check/asset-rules.js +163 -0
- package/dist/check/browser-sources.d.ts +20 -0
- package/dist/check/browser-sources.js +89 -0
- package/dist/check/console-rules.d.ts +6 -0
- package/dist/check/console-rules.js +40 -0
- package/dist/check/error-rules.d.ts +21 -0
- package/dist/check/error-rules.js +212 -0
- package/dist/check/form-rules.d.ts +12 -0
- package/dist/check/form-rules.js +69 -6
- package/dist/check/formatter-rules.d.ts +2 -0
- package/dist/check/formatter-rules.js +71 -0
- package/dist/check/i18n-rules.d.ts +27 -0
- package/dist/check/i18n-rules.js +353 -0
- package/dist/check/index.d.ts +15 -1
- package/dist/check/index.js +89 -11
- package/dist/check/mail-rules.d.ts +4 -0
- package/dist/check/mail-rules.js +72 -0
- package/dist/check/module-rules.d.ts +13 -0
- package/dist/check/module-rules.js +104 -0
- package/dist/check/packages.d.ts +8 -0
- package/dist/check/packages.js +16 -0
- package/dist/check/pool-rules.d.ts +8 -0
- package/dist/check/pool-rules.js +164 -0
- package/dist/check/problems.d.ts +6 -1
- package/dist/check/problems.js +100 -2
- package/dist/check/project-rules.d.ts +23 -3
- package/dist/check/project-rules.js +265 -23
- package/dist/check/seal-rules.d.ts +6 -0
- package/dist/check/seal-rules.js +68 -0
- package/dist/check/security-rules.d.ts +10 -0
- package/dist/check/security-rules.js +450 -0
- package/dist/check/shared-state-rules.d.ts +15 -0
- package/dist/check/shared-state-rules.js +117 -0
- package/dist/check/source-rules.d.ts +26 -3
- package/dist/check/source-rules.js +249 -28
- package/dist/check/structure-rules.d.ts +23 -0
- package/dist/check/structure-rules.js +502 -0
- package/dist/check/timer-rules.d.ts +7 -0
- package/dist/check/timer-rules.js +543 -0
- package/dist/check/typescript.d.ts +35 -5
- package/dist/check/typescript.js +163 -17
- package/dist/check/vite-rules.d.ts +2 -0
- package/dist/check/vite-rules.js +136 -0
- package/dist/check/wait-rules.d.ts +16 -0
- package/dist/check/wait-rules.js +337 -0
- package/dist/commands.d.ts +7 -0
- package/dist/commands.js +381 -36
- package/dist/config-diff.d.ts +42 -0
- package/dist/config-diff.js +289 -0
- package/dist/config-edit.d.ts +35 -2
- package/dist/config-edit.js +145 -13
- package/dist/config-init.d.ts +15 -0
- package/dist/config-init.js +61 -0
- package/dist/config-paths.d.ts +22 -0
- package/dist/config-paths.js +82 -0
- package/dist/config-secrets.d.ts +46 -0
- package/dist/config-secrets.js +187 -0
- package/dist/config-show.d.ts +82 -0
- package/dist/config-show.js +563 -0
- package/dist/config-values.d.ts +8 -0
- package/dist/config-values.js +68 -0
- package/dist/configuration-scaffold.d.ts +42 -0
- package/dist/configuration-scaffold.js +258 -4
- package/dist/configuration.d.ts +63 -3
- package/dist/configuration.js +178 -4
- package/dist/deploy-scripts.d.ts +4 -0
- package/dist/deploy-scripts.js +361 -0
- package/dist/deploy-templates.d.ts +46 -0
- package/dist/deploy-templates.js +379 -0
- package/dist/deploy.d.ts +46 -0
- package/dist/deploy.js +592 -0
- package/dist/deployment.d.ts +2 -0
- package/dist/deployment.js +111 -0
- package/dist/describe-db.d.ts +22 -0
- package/dist/describe-db.js +176 -0
- package/dist/describe.d.ts +92 -0
- package/dist/describe.js +417 -0
- package/dist/dev-compose.d.ts +20 -0
- package/dist/dev-compose.js +154 -0
- package/dist/dev-guard.js +44 -0
- package/dist/dev-host.d.ts +13 -0
- package/dist/dev-host.js +55 -0
- package/dist/dev-infra.d.ts +72 -4
- package/dist/dev-infra.js +237 -17
- package/dist/dev-watch.d.ts +34 -0
- package/dist/dev-watch.js +192 -0
- package/dist/diagnostics.d.ts +10 -7
- package/dist/diagnostics.js +22 -4
- package/dist/drift.d.ts +50 -0
- package/dist/drift.js +218 -0
- package/dist/env-file.d.ts +25 -1
- package/dist/env-file.js +64 -6
- package/dist/errors.d.ts +7 -1
- package/dist/example-config.d.ts +2 -0
- package/dist/example-config.js +3 -0
- package/dist/first-admin.d.ts +43 -0
- package/dist/first-admin.js +175 -0
- package/dist/formatter-ignore.d.ts +37 -0
- package/dist/formatter-ignore.js +344 -0
- package/dist/generator.d.ts +45 -3
- package/dist/generator.js +205 -15
- package/dist/guide.d.ts +1 -1
- package/dist/guide.js +42 -21
- package/dist/install-module.d.ts +20 -2
- package/dist/install-module.js +45 -8
- package/dist/install-plan.js +5 -3
- package/dist/layout.d.ts +12 -1
- package/dist/layout.js +19 -6
- package/dist/log-writer-control.d.ts +2 -0
- package/dist/log-writer-control.js +17 -2
- package/dist/log-writer.js +43 -10
- package/dist/logging-config.d.ts +8 -3
- package/dist/logging-config.js +19 -5
- package/dist/manifest.d.ts +29 -1
- package/dist/manifest.js +84 -6
- package/dist/merge-install-defaults.d.ts +0 -1
- package/dist/merge-install-defaults.js +13 -47
- package/dist/npm-client.d.ts +40 -4
- package/dist/npm-client.js +152 -15
- package/dist/npm-paths.js +18 -4
- package/dist/openapi.d.ts +27 -0
- package/dist/openapi.js +649 -0
- package/dist/option-fixes.d.ts +39 -0
- package/dist/option-fixes.js +130 -0
- package/dist/output.d.ts +1 -1
- package/dist/output.js +5 -5
- package/dist/plugin-lifecycle.d.ts +34 -2
- package/dist/plugin-lifecycle.js +195 -16
- package/dist/plugin-options.d.ts +19 -4
- package/dist/plugin-options.js +138 -6
- package/dist/plugins-command.js +44 -7
- package/dist/process-group.d.ts +39 -0
- package/dist/process-group.js +132 -0
- package/dist/production-doctor.d.ts +3 -0
- package/dist/production-doctor.js +176 -0
- package/dist/project-guide.d.ts +19 -1
- package/dist/project-guide.js +119 -20
- package/dist/project-inspector.d.ts +15 -0
- package/dist/project-inspector.js +497 -33
- package/dist/project-state.d.ts +5 -0
- package/dist/project-state.js +8 -0
- package/dist/project.d.ts +203 -19
- package/dist/project.js +3006 -333
- package/dist/recipe-facts.d.ts +127 -0
- package/dist/recipe-facts.js +563 -0
- package/dist/recipe-login.d.ts +63 -0
- package/dist/recipe-login.js +464 -0
- package/dist/recipe-mail.d.ts +21 -0
- package/dist/recipe-mail.js +214 -0
- package/dist/recipe-members.d.ts +35 -0
- package/dist/recipe-members.js +178 -0
- package/dist/recipe-record.d.ts +82 -7
- package/dist/recipe-record.js +226 -22
- package/dist/recipe-reset.d.ts +2 -0
- package/dist/recipe-reset.js +117 -0
- package/dist/recipe-site.d.ts +48 -0
- package/dist/recipe-site.js +396 -0
- package/dist/recipe-step.d.ts +42 -0
- package/dist/recipe-step.js +89 -0
- package/dist/recipe.d.ts +56 -20
- package/dist/recipe.js +703 -131
- package/dist/registry.d.ts +98 -2
- package/dist/registry.js +171 -5
- package/dist/reloadable.d.ts +35 -0
- package/dist/reloadable.js +257 -0
- package/dist/remote-verify.d.ts +31 -0
- package/dist/remote-verify.js +204 -0
- package/dist/server.d.ts +43 -4
- package/dist/server.js +601 -52
- package/dist/settings.d.ts +48 -0
- package/dist/settings.js +168 -0
- package/dist/site-auth.d.ts +39 -0
- package/dist/site-auth.js +195 -0
- package/dist/template-fixes.d.ts +26 -0
- package/dist/template-fixes.js +285 -0
- package/dist/test-reporter.d.ts +49 -0
- package/dist/test-reporter.js +190 -0
- package/dist/transaction.d.ts +47 -0
- package/dist/transaction.js +241 -7
- package/dist/verify-tests.d.ts +98 -0
- package/dist/verify-tests.js +449 -0
- package/dist/verify.d.ts +103 -3
- package/dist/verify.js +272 -21
- package/dist/view-choice.d.ts +58 -0
- package/dist/view-choice.js +131 -0
- package/dist/vite-watch.js +4 -2
- package/package.json +8 -6
package/dist/transaction.js
CHANGED
|
@@ -4,6 +4,7 @@ import { hash as hashing } from '@dokebi/core/libraries';
|
|
|
4
4
|
import { debug, debugEnabled, describeError, reportError } from './diagnostics.js';
|
|
5
5
|
import { CliError } from './errors.js';
|
|
6
6
|
import { exists, fingerprint, hash, json, securePath } from './filesystem.js';
|
|
7
|
+
import { lockDiagnostic, lockHolder } from './project-inspector.js';
|
|
7
8
|
/**
|
|
8
9
|
* What is at `path` now, without following a symlink: null when nothing is there, `file:<sha256>`
|
|
9
10
|
* for a file, `directory:<dev>:<ino>` for a folder (its identity: a folder's content is not
|
|
@@ -34,11 +35,156 @@ function differs(installed, now) {
|
|
|
34
35
|
return 'symlink';
|
|
35
36
|
return installed?.startsWith('file:') && now.startsWith('file:') ? 'modified' : 'replaced';
|
|
36
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
|
+
}
|
|
37
150
|
export class Transaction {
|
|
38
151
|
root;
|
|
39
152
|
directory;
|
|
40
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
|
+
}
|
|
41
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;
|
|
42
188
|
#keep = false;
|
|
43
189
|
constructor(root, directory) {
|
|
44
190
|
this.root = root;
|
|
@@ -53,7 +199,25 @@ export class Transaction {
|
|
|
53
199
|
handle = await open(lock, 'wx');
|
|
54
200
|
}
|
|
55
201
|
catch (cause) {
|
|
56
|
-
|
|
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
|
+
});
|
|
57
221
|
}
|
|
58
222
|
try {
|
|
59
223
|
await handle.writeFile(json({ pid: process.pid }));
|
|
@@ -61,7 +225,9 @@ export class Transaction {
|
|
|
61
225
|
if ((await readdir(control)).some((name) => name.startsWith('transaction-'))) {
|
|
62
226
|
throw new CliError('RECOVERY_REQUIRED', 'Interrupted transaction found; preserve .dokebi/transaction-* for recovery', { phase: 'unknown' });
|
|
63
227
|
}
|
|
64
|
-
|
|
228
|
+
const transaction = new Transaction(root, await mkdtemp(join(control, 'transaction-')));
|
|
229
|
+
watchSignals(transaction);
|
|
230
|
+
return transaction;
|
|
65
231
|
}
|
|
66
232
|
catch (cause) {
|
|
67
233
|
await handle.close().catch((error) => {
|
|
@@ -83,7 +249,7 @@ export class Transaction {
|
|
|
83
249
|
const planned = this.#changes.find((change) => change.path === current);
|
|
84
250
|
if (planned) {
|
|
85
251
|
if (planned.kind !== 'directory')
|
|
86
|
-
throw
|
|
252
|
+
throw duplicateChange(current);
|
|
87
253
|
continue;
|
|
88
254
|
}
|
|
89
255
|
await this.capture(current);
|
|
@@ -91,15 +257,55 @@ export class Transaction {
|
|
|
91
257
|
if (expected === null)
|
|
92
258
|
this.#changes.push({ path: current, kind: 'directory' });
|
|
93
259
|
else if (!expected?.startsWith('directory:'))
|
|
94
|
-
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;
|
|
95
279
|
}
|
|
96
280
|
}
|
|
97
281
|
async read(path) {
|
|
98
282
|
await this.capture(path);
|
|
99
283
|
return readFile(await securePath(this.root, path), 'utf8');
|
|
100
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;
|
|
101
290
|
async write(path, content) {
|
|
102
|
-
|
|
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++}`);
|
|
103
309
|
await writeFile(source, content, { mode: 0o600 });
|
|
104
310
|
await this.#add(path, source, `file:${hash(content)}`);
|
|
105
311
|
}
|
|
@@ -107,10 +313,17 @@ export class Transaction {
|
|
|
107
313
|
async replace(path, source) {
|
|
108
314
|
await this.#add(path, source);
|
|
109
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
|
+
}
|
|
110
320
|
async #add(path, source, installs) {
|
|
111
|
-
if (this.#
|
|
112
|
-
throw
|
|
321
|
+
if (this.#claimed(path))
|
|
322
|
+
throw duplicateChange(path);
|
|
113
323
|
await this.capture(path);
|
|
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);
|
|
114
327
|
this.#changes.push(source === undefined ? { path } : installs ? { path, source, installs } : { path, source });
|
|
115
328
|
}
|
|
116
329
|
get paths() {
|
|
@@ -126,16 +339,36 @@ export class Transaction {
|
|
|
126
339
|
* in the transaction folder, and the commit ends with RECOVERY_REQUIRED naming those paths.
|
|
127
340
|
*/
|
|
128
341
|
async commit(beforeApply, afterApply) {
|
|
342
|
+
if (this.#interrupted)
|
|
343
|
+
throw interruptedError(this.#interrupted);
|
|
344
|
+
this.#committing = true;
|
|
129
345
|
for (const [path, expected] of this.#snapshots) {
|
|
130
346
|
if ((await fingerprint(await securePath(this.root, path))) !== expected) {
|
|
131
347
|
throw new CliError('CONCURRENT_CHANGE', `File changed while planning: ${path}`);
|
|
132
348
|
}
|
|
133
349
|
}
|
|
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) {
|
|
134
365
|
const journal = [];
|
|
135
366
|
for (const [index, change] of this.#changes.entries()) {
|
|
136
367
|
// What this change installs, to recognise it again in a rollback: a written file's hash, a
|
|
137
368
|
// staged file's hash or a staged folder's identity (node_modules).
|
|
138
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'));
|
|
139
372
|
journal.push({
|
|
140
373
|
...change,
|
|
141
374
|
...(installs ? { installs } : {}),
|
|
@@ -317,6 +550,7 @@ export class Transaction {
|
|
|
317
550
|
return true;
|
|
318
551
|
}
|
|
319
552
|
async close() {
|
|
553
|
+
unwatchSignals(this);
|
|
320
554
|
if (this.#keep)
|
|
321
555
|
return;
|
|
322
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;
|