@dokebi/cli 0.4.1 → 0.4.2
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 +5 -5
- package/README.md +1 -1
- package/catalog.json +18 -18
- package/dist/bin.js +9 -0
- package/dist/install-module.d.ts +9 -1
- package/dist/install-module.js +97 -5
- package/dist/log-writer-control.js +89 -25
- package/dist/npm-client.d.ts +19 -0
- package/dist/npm-client.js +68 -1
- package/dist/output.d.ts +1 -1
- package/dist/output.js +1 -1
- package/dist/plugin-lifecycle.js +15 -8
- package/dist/project.js +10 -3
- package/dist/server.d.ts +7 -0
- package/dist/server.js +189 -51
- package/dist/transaction.d.ts +20 -0
- package/dist/transaction.js +187 -39
- package/dist/verify.d.ts +10 -1
- package/dist/verify.js +55 -8
- package/package.json +1 -1
package/dist/transaction.js
CHANGED
|
@@ -1,8 +1,39 @@
|
|
|
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
|
+
/**
|
|
8
|
+
* What is at `path` now, without following a symlink: null when nothing is there, `file:<sha256>`
|
|
9
|
+
* for a file, `directory:<dev>:<ino>` for a folder (its identity: a folder's content is not
|
|
10
|
+
* hashed), `symlink` or `other`.
|
|
11
|
+
*/
|
|
12
|
+
async function observe(path) {
|
|
13
|
+
let stat;
|
|
14
|
+
try {
|
|
15
|
+
stat = await lstat(path);
|
|
16
|
+
}
|
|
17
|
+
catch (error) {
|
|
18
|
+
if (error.code === 'ENOENT')
|
|
19
|
+
return null;
|
|
20
|
+
throw error;
|
|
21
|
+
}
|
|
22
|
+
if (stat.isSymbolicLink())
|
|
23
|
+
return 'symlink';
|
|
24
|
+
if (stat.isDirectory())
|
|
25
|
+
return `directory:${stat.dev}:${stat.ino}`;
|
|
26
|
+
if (!stat.isFile())
|
|
27
|
+
return 'other';
|
|
28
|
+
return `file:${await hashing.sha256File(path)}`;
|
|
29
|
+
}
|
|
30
|
+
const LINK_UNSUPPORTED = new Set(['EPERM', 'ENOTSUP', 'EOPNOTSUPP', 'EMLINK', 'EXDEV']);
|
|
31
|
+
/** Why `now` is not the `installed` fingerprint (both as `observe` writes them). */
|
|
32
|
+
function differs(installed, now) {
|
|
33
|
+
if (now === 'symlink')
|
|
34
|
+
return 'symlink';
|
|
35
|
+
return installed?.startsWith('file:') && now.startsWith('file:') ? 'modified' : 'replaced';
|
|
36
|
+
}
|
|
6
37
|
export class Transaction {
|
|
7
38
|
root;
|
|
8
39
|
directory;
|
|
@@ -70,13 +101,17 @@ export class Transaction {
|
|
|
70
101
|
async write(path, content) {
|
|
71
102
|
const source = join(this.directory, `new-${this.#changes.length}`);
|
|
72
103
|
await writeFile(source, content, { mode: 0o600 });
|
|
73
|
-
await this
|
|
104
|
+
await this.#add(path, source, `file:${hash(content)}`);
|
|
74
105
|
}
|
|
106
|
+
/** Puts `source` (a file or folder) at `path` on commit; without `source`, removes `path`. */
|
|
75
107
|
async replace(path, source) {
|
|
108
|
+
await this.#add(path, source);
|
|
109
|
+
}
|
|
110
|
+
async #add(path, source, installs) {
|
|
76
111
|
if (this.#changes.some((change) => change.path === path))
|
|
77
112
|
throw new CliError('DUPLICATE_CHANGE', path);
|
|
78
113
|
await this.capture(path);
|
|
79
|
-
this.#changes.push(source === undefined ? { path } : { path, source });
|
|
114
|
+
this.#changes.push(source === undefined ? { path } : installs ? { path, source, installs } : { path, source });
|
|
80
115
|
}
|
|
81
116
|
get paths() {
|
|
82
117
|
return this.#changes.map((change) => change.path);
|
|
@@ -84,6 +119,11 @@ export class Transaction {
|
|
|
84
119
|
/**
|
|
85
120
|
* Applies every change; `afterApply` runs once they are all in place, and when it fails the
|
|
86
121
|
* changes are rolled back like a failed apply (D-0035: the workspace npm install).
|
|
122
|
+
*
|
|
123
|
+
* The rollback only undoes what is still this commit's own (ISSUE-0006 A06): `afterApply` can
|
|
124
|
+
* run for minutes while the user edits files. A path that is no longer what this commit put
|
|
125
|
+
* there (or, for a removed path, where something appeared) is left as it is, its original stays
|
|
126
|
+
* in the transaction folder, and the commit ends with RECOVERY_REQUIRED naming those paths.
|
|
87
127
|
*/
|
|
88
128
|
async commit(beforeApply, afterApply) {
|
|
89
129
|
for (const [path, expected] of this.#snapshots) {
|
|
@@ -91,13 +131,20 @@ export class Transaction {
|
|
|
91
131
|
throw new CliError('CONCURRENT_CHANGE', `File changed while planning: ${path}`);
|
|
92
132
|
}
|
|
93
133
|
}
|
|
94
|
-
const journal =
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
134
|
+
const journal = [];
|
|
135
|
+
for (const [index, change] of this.#changes.entries()) {
|
|
136
|
+
// What this change installs, to recognise it again in a rollback: a written file's hash, a
|
|
137
|
+
// staged file's hash or a staged folder's identity (node_modules).
|
|
138
|
+
const installs = change.installs ?? (change.source ? await observe(change.source) : null) ?? undefined;
|
|
139
|
+
journal.push({
|
|
140
|
+
...change,
|
|
141
|
+
...(installs ? { installs } : {}),
|
|
142
|
+
backup: join(this.directory, `old-${index}`),
|
|
143
|
+
existed: this.#snapshots.get(change.path) !== null,
|
|
144
|
+
backedUp: false,
|
|
145
|
+
installed: false,
|
|
146
|
+
});
|
|
147
|
+
}
|
|
101
148
|
await writeFile(join(this.directory, 'journal.json'), json(journal));
|
|
102
149
|
try {
|
|
103
150
|
for (const [index, change] of journal.entries()) {
|
|
@@ -134,39 +181,140 @@ export class Transaction {
|
|
|
134
181
|
changes: journal.length,
|
|
135
182
|
error: describeError(cause),
|
|
136
183
|
});
|
|
137
|
-
|
|
138
|
-
|
|
184
|
+
await this.#rollback(journal, cause);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
async #rollback(journal, cause) {
|
|
188
|
+
const failures = [];
|
|
189
|
+
const conflicts = [];
|
|
190
|
+
for (const [index, change] of [...journal.entries()].reverse()) {
|
|
191
|
+
try {
|
|
192
|
+
const conflict = await this.#undo(change, index);
|
|
193
|
+
if (conflict) {
|
|
194
|
+
debug('Rollback kept a path changed during the commit', { ...conflict });
|
|
195
|
+
conflicts.push(conflict);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
catch (error) {
|
|
199
|
+
failures.push(error);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
if (failures.length || conflicts.length) {
|
|
203
|
+
this.#keep = true;
|
|
204
|
+
const directory = relative(this.root, this.directory);
|
|
205
|
+
const kept = conflicts.length
|
|
206
|
+
? `kept ${conflicts.length} path(s) changed during the operation: ${conflicts
|
|
207
|
+
.map((item) => `${item.path} (${item.reason})`)
|
|
208
|
+
.join(', ')}; `
|
|
209
|
+
: '';
|
|
210
|
+
const failed = failures.length ? `${failures.length} step(s) failed; ` : '';
|
|
211
|
+
throw new CliError('RECOVERY_REQUIRED', `Rollback ${kept}${failed}preserve ${directory} and .dokebi/lock`, {
|
|
212
|
+
cause: failures.length ? new AggregateError([cause, ...failures]) : cause,
|
|
213
|
+
phase: 'commit',
|
|
214
|
+
details: { conflicts, transaction: directory },
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
throw new CliError('COMMIT_FAILED', 'Changes were rolled back', { cause });
|
|
218
|
+
}
|
|
219
|
+
/**
|
|
220
|
+
* Undoes one change unless the path is no longer this commit's own: then leaves it and says
|
|
221
|
+
* why. The look and the removal cannot be one atomic step; the window is kept to a rename
|
|
222
|
+
* (see `#undo`'s move-aside) and documented in docs/cli/README.md.
|
|
223
|
+
*/
|
|
224
|
+
async #undo(change, index) {
|
|
225
|
+
const backup = change.backedUp ? { backup: relative(this.root, change.backup) } : {};
|
|
226
|
+
let target;
|
|
227
|
+
try {
|
|
228
|
+
// The parent folders, never following a symlink; the path itself is looked at below.
|
|
229
|
+
const parent = dirname(change.path);
|
|
230
|
+
const folder = parent === '.' ? this.root : await securePath(this.root, parent);
|
|
231
|
+
target = join(folder, basename(change.path));
|
|
232
|
+
}
|
|
233
|
+
catch (error) {
|
|
234
|
+
const code = error instanceof CliError ? error.code : undefined;
|
|
235
|
+
if (code !== 'UNSAFE_PATH' && code !== 'PATH_CONFLICT')
|
|
236
|
+
throw error;
|
|
237
|
+
return {
|
|
238
|
+
path: change.path,
|
|
239
|
+
reason: code === 'UNSAFE_PATH' ? 'unsafe-path' : 'path-conflict',
|
|
240
|
+
...backup,
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
if (change.kind === 'directory') {
|
|
244
|
+
if (!change.installed)
|
|
245
|
+
return undefined;
|
|
246
|
+
try {
|
|
247
|
+
// rmdir removes only an empty folder and never follows a symlink.
|
|
248
|
+
await rmdir(target);
|
|
249
|
+
}
|
|
250
|
+
catch (error) {
|
|
251
|
+
const code = error.code;
|
|
252
|
+
if (code === 'ENOENT')
|
|
253
|
+
return undefined;
|
|
254
|
+
if (code === 'ENOTEMPTY' || code === 'EEXIST')
|
|
255
|
+
return { path: change.path, reason: 'not-empty' };
|
|
256
|
+
if (code === 'ENOTDIR')
|
|
257
|
+
return { path: change.path, reason: 'replaced' };
|
|
258
|
+
throw error;
|
|
259
|
+
}
|
|
260
|
+
return undefined;
|
|
261
|
+
}
|
|
262
|
+
if (change.installed) {
|
|
263
|
+
const now = await observe(target);
|
|
264
|
+
if (now !== null) {
|
|
265
|
+
if (now !== change.installs)
|
|
266
|
+
return { path: change.path, reason: differs(change.installs, now), ...backup };
|
|
267
|
+
// Move it aside (atomic), then look again: an edit between the look and the move is
|
|
268
|
+
// found here and put back, never deleted.
|
|
269
|
+
const aside = join(this.directory, `rollback-${index}`);
|
|
139
270
|
try {
|
|
140
|
-
|
|
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);
|
|
271
|
+
await rename(target, aside);
|
|
157
272
|
}
|
|
158
273
|
catch (error) {
|
|
159
|
-
|
|
274
|
+
if (error.code !== 'ENOENT')
|
|
275
|
+
throw error;
|
|
160
276
|
}
|
|
277
|
+
const moved = await observe(aside);
|
|
278
|
+
if (moved !== null && moved !== change.installs) {
|
|
279
|
+
const restored = await this.#place(aside, target);
|
|
280
|
+
return {
|
|
281
|
+
path: change.path,
|
|
282
|
+
reason: differs(change.installs, moved),
|
|
283
|
+
...backup,
|
|
284
|
+
...(restored ? {} : { kept: relative(this.root, aside) }),
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
if (moved !== null)
|
|
288
|
+
await rm(aside, { recursive: true, force: true });
|
|
161
289
|
}
|
|
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
290
|
}
|
|
291
|
+
if (change.backedUp && !(await this.#place(change.backup, target)))
|
|
292
|
+
return { path: change.path, reason: 'created', ...backup };
|
|
293
|
+
return undefined;
|
|
294
|
+
}
|
|
295
|
+
/**
|
|
296
|
+
* Moves `from` to `to` without replacing anything at `to`: false when something is there. A
|
|
297
|
+
* file goes by link + unlink (link fails on an existing name); a folder (or a file system
|
|
298
|
+
* without hard links) by rename after checking `to` is free, which leaves a tiny window.
|
|
299
|
+
*/
|
|
300
|
+
async #place(from, to) {
|
|
301
|
+
try {
|
|
302
|
+
await link(from, to);
|
|
303
|
+
await unlink(from);
|
|
304
|
+
return true;
|
|
305
|
+
}
|
|
306
|
+
catch (error) {
|
|
307
|
+
const code = error.code ?? '';
|
|
308
|
+
if (code === 'EEXIST')
|
|
309
|
+
return false;
|
|
310
|
+
if (!LINK_UNSUPPORTED.has(code))
|
|
311
|
+
throw error;
|
|
312
|
+
debug('Rollback restores by rename (no hard link)', { path: to, code });
|
|
313
|
+
}
|
|
314
|
+
if ((await observe(to)) !== null)
|
|
315
|
+
return false;
|
|
316
|
+
await rename(from, to);
|
|
317
|
+
return true;
|
|
170
318
|
}
|
|
171
319
|
async close() {
|
|
172
320
|
if (this.#keep)
|
package/dist/verify.d.ts
CHANGED
|
@@ -46,17 +46,26 @@ export interface HttpProbe {
|
|
|
46
46
|
readonly ok: boolean;
|
|
47
47
|
/** `other`: an answer without this run's token header — another server on the port. */
|
|
48
48
|
readonly instance?: 'ours' | 'other';
|
|
49
|
+
/**
|
|
50
|
+
* The identity probe (no readiness route and no app domain): `GET /` passes with any status
|
|
51
|
+
* below 500 that carries this run's header.
|
|
52
|
+
*/
|
|
53
|
+
readonly identity?: true;
|
|
49
54
|
readonly error?: string;
|
|
50
55
|
}
|
|
51
56
|
/**
|
|
52
57
|
* The HTTP check of verify: GET the readiness path, then `/` of each app domain, at `base`. An
|
|
53
58
|
* answer counts only when it carries `token` in the x-dokebi-verify header (the instance verify
|
|
54
59
|
* started); anything else is retried like a failed request and then reported FOREIGN_SERVER.
|
|
60
|
+
* With neither a readiness path (`healthRoutes: false`) nor an app domain, one identity probe
|
|
61
|
+
* runs instead (ISSUE-0006 A08: no request at all used to pass): `GET /`, which passes with any
|
|
62
|
+
* status below 500 that carries the token, a 404 included (the app may have no route at `/`).
|
|
63
|
+
* Each request must complete within `timeoutMs` (ISSUE-0006 A09).
|
|
55
64
|
*/
|
|
56
65
|
export declare function httpChecks(base: string, paths: {
|
|
57
66
|
ready: string | null;
|
|
58
67
|
domains: readonly string[];
|
|
59
|
-
}, token: string, delayMs?: number): Promise<Omit<VerifyCheck, 'name' | 'ms'>>;
|
|
68
|
+
}, token: string, delayMs?: number, timeoutMs?: number): Promise<Omit<VerifyCheck, 'name' | 'ms'>>;
|
|
60
69
|
export declare function verify(root: string, options?: {
|
|
61
70
|
waitMs?: number;
|
|
62
71
|
}): Promise<VerifyResult>;
|
package/dist/verify.js
CHANGED
|
@@ -61,10 +61,31 @@ export async function npmScript(root, script, timeoutMs) {
|
|
|
61
61
|
export const VERIFY_TOKEN_ENV = 'DOKEBI_VERIFY_TOKEN';
|
|
62
62
|
/** The response header @dokebi/web answers with when that variable is set. */
|
|
63
63
|
export const VERIFY_HEADER = 'x-dokebi-verify';
|
|
64
|
-
/**
|
|
64
|
+
/**
|
|
65
|
+
* GET with a Host header (fetch cannot set one). Settles once: with the answer when the body
|
|
66
|
+
* ended, or with an error when the request fails, the response is aborted or its connection
|
|
67
|
+
* closes before the end (ISSUE-0006 A09), or nothing complete came within `timeoutMs` in all
|
|
68
|
+
* (the socket timeout alone never fires for a server that keeps trickling bytes).
|
|
69
|
+
*/
|
|
65
70
|
function get(url, host, timeoutMs = 5000) {
|
|
66
71
|
return new Promise((resolve, reject) => {
|
|
67
72
|
const target = new URL(url);
|
|
73
|
+
let settled = false;
|
|
74
|
+
const finish = (error, answer) => {
|
|
75
|
+
if (settled)
|
|
76
|
+
return;
|
|
77
|
+
settled = true;
|
|
78
|
+
clearTimeout(deadline);
|
|
79
|
+
if (error) {
|
|
80
|
+
// Frees the socket of a response that will never complete.
|
|
81
|
+
if (!req.destroyed)
|
|
82
|
+
req.destroy();
|
|
83
|
+
reject(error);
|
|
84
|
+
}
|
|
85
|
+
else
|
|
86
|
+
resolve(answer);
|
|
87
|
+
};
|
|
88
|
+
const failed = (error) => finish(error);
|
|
68
89
|
const req = request({
|
|
69
90
|
host: target.hostname.replace(/^\[|\]$/g, ''),
|
|
70
91
|
port: target.port,
|
|
@@ -73,14 +94,23 @@ function get(url, host, timeoutMs = 5000) {
|
|
|
73
94
|
timeout: timeoutMs,
|
|
74
95
|
}, (response) => {
|
|
75
96
|
response.resume();
|
|
76
|
-
response.once('end', () =>
|
|
97
|
+
response.once('end', () => finish(undefined, {
|
|
77
98
|
status: response.statusCode ?? 0,
|
|
78
99
|
type: String(response.headers['content-type'] ?? ''),
|
|
79
100
|
instance: response.headers[VERIFY_HEADER],
|
|
80
101
|
}));
|
|
102
|
+
response.once('aborted', () => finish(new Error('The response was aborted')));
|
|
103
|
+
// Error listeners stay after settling: a late error (the destroy below) is then ignored
|
|
104
|
+
// by `finish` instead of being an unhandled 'error' event.
|
|
105
|
+
response.on('error', failed);
|
|
106
|
+
response.once('close', () => {
|
|
107
|
+
if (!response.complete)
|
|
108
|
+
finish(new Error('The connection closed before the response ended'));
|
|
109
|
+
});
|
|
81
110
|
});
|
|
111
|
+
const deadline = setTimeout(() => finish(new Error(`No complete answer within ${timeoutMs} ms`)), timeoutMs);
|
|
82
112
|
req.once('timeout', () => req.destroy(new Error(`No answer within ${timeoutMs} ms`)));
|
|
83
|
-
req.
|
|
113
|
+
req.on('error', failed);
|
|
84
114
|
req.end();
|
|
85
115
|
});
|
|
86
116
|
}
|
|
@@ -123,18 +153,30 @@ async function settings(root) {
|
|
|
123
153
|
* The HTTP check of verify: GET the readiness path, then `/` of each app domain, at `base`. An
|
|
124
154
|
* answer counts only when it carries `token` in the x-dokebi-verify header (the instance verify
|
|
125
155
|
* started); anything else is retried like a failed request and then reported FOREIGN_SERVER.
|
|
156
|
+
* With neither a readiness path (`healthRoutes: false`) nor an app domain, one identity probe
|
|
157
|
+
* runs instead (ISSUE-0006 A08: no request at all used to pass): `GET /`, which passes with any
|
|
158
|
+
* status below 500 that carries the token, a 404 included (the app may have no route at `/`).
|
|
159
|
+
* Each request must complete within `timeoutMs` (ISSUE-0006 A09).
|
|
126
160
|
*/
|
|
127
|
-
export async function httpChecks(base, paths, token, delayMs = 250) {
|
|
161
|
+
export async function httpChecks(base, paths, token, delayMs = 250, timeoutMs = 5000) {
|
|
128
162
|
const requests = [];
|
|
129
|
-
const probe = async (path, host, attempts) => {
|
|
163
|
+
const probe = async (path, host, attempts, identity = false) => {
|
|
130
164
|
const url = `${base}${path}`;
|
|
165
|
+
const kind = identity ? { identity: true } : {};
|
|
131
166
|
for (let attempt = 1;; attempt++) {
|
|
132
167
|
try {
|
|
133
|
-
const answer = await get(url, host);
|
|
168
|
+
const answer = await get(url, host, timeoutMs);
|
|
134
169
|
const instance = answer.instance === token ? 'ours' : 'other';
|
|
135
|
-
const ok = answer.status >= 200 && answer.status < 400 && instance === 'ours';
|
|
170
|
+
const ok = answer.status >= 200 && answer.status < (identity ? 500 : 400) && instance === 'ours';
|
|
136
171
|
if (ok || attempt >= attempts) {
|
|
137
|
-
requests.push({
|
|
172
|
+
requests.push({
|
|
173
|
+
url,
|
|
174
|
+
...(host ? { host } : {}),
|
|
175
|
+
status: answer.status,
|
|
176
|
+
ok,
|
|
177
|
+
instance,
|
|
178
|
+
...kind,
|
|
179
|
+
});
|
|
138
180
|
return;
|
|
139
181
|
}
|
|
140
182
|
if (instance === 'other')
|
|
@@ -151,6 +193,7 @@ export async function httpChecks(base, paths, token, delayMs = 250) {
|
|
|
151
193
|
status: 0,
|
|
152
194
|
ok: false,
|
|
153
195
|
error: error instanceof Error ? error.message : String(error),
|
|
196
|
+
...kind,
|
|
154
197
|
});
|
|
155
198
|
return;
|
|
156
199
|
}
|
|
@@ -163,6 +206,10 @@ export async function httpChecks(base, paths, token, delayMs = 250) {
|
|
|
163
206
|
await probe(paths.ready, undefined, 20);
|
|
164
207
|
for (const domain of paths.domains)
|
|
165
208
|
await probe('/', domain, 4);
|
|
209
|
+
if (!paths.ready && !paths.domains.length) {
|
|
210
|
+
debug('verify: no readiness route and no app domain; probing GET / for this run', { base });
|
|
211
|
+
await probe('/', undefined, 20, true);
|
|
212
|
+
}
|
|
166
213
|
const failed = requests.filter((item) => !item.ok);
|
|
167
214
|
const foreign = failed.filter((item) => item.instance === 'other');
|
|
168
215
|
return {
|