@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.
- package/AI.md +262 -57
- package/README.md +35 -10
- package/catalog.json +2519 -257
- 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 +527 -35
- 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 +2 -0
- package/dist/check/shared-state-rules.js +114 -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 +307 -31
- 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-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/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 +29 -3
- package/dist/install-module.js +142 -13
- 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 +106 -27
- 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 +59 -4
- package/dist/npm-client.js +220 -16
- 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 +206 -20
- 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/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 +481 -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 +3016 -336
- 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/server.d.ts +50 -4
- package/dist/server.js +790 -103
- 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 +67 -0
- package/dist/transaction.js +428 -46
- package/dist/verify-tests.d.ts +98 -0
- package/dist/verify-tests.js +449 -0
- package/dist/verify.d.ts +113 -4
- package/dist/verify.js +326 -28
- 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/server.js
CHANGED
|
@@ -27,20 +27,22 @@
|
|
|
27
27
|
import { execFile, spawn } from 'node:child_process';
|
|
28
28
|
import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
|
|
29
29
|
import { existsSync, readFileSync, watch } from 'node:fs';
|
|
30
|
-
import { mkdir, open, readdir, readFile, rename, rm, stat, writeFile, } from 'node:fs/promises';
|
|
30
|
+
import { mkdir, open, readdir, readFile, rename, rm, stat, utimes, writeFile, } from 'node:fs/promises';
|
|
31
31
|
import { request as httpRequest } from 'node:http';
|
|
32
32
|
import { request as httpsRequest } from 'node:https';
|
|
33
|
-
import { basename, dirname, join } from 'node:path';
|
|
33
|
+
import { basename, dirname, join, relative } from 'node:path';
|
|
34
34
|
import { setTimeout as delay } from 'node:timers/promises';
|
|
35
35
|
import { fileURLToPath } from 'node:url';
|
|
36
36
|
import { promisify } from 'node:util';
|
|
37
37
|
import { appUrls, readAppHosts } from './app-urls.js';
|
|
38
|
-
import { CONFIG_FILE, parseProjectConfig } from './configuration.js';
|
|
38
|
+
import { CONFIG_FILE, configTarget, parseProjectConfig, readProjectSettings, } from './configuration.js';
|
|
39
|
+
import { WatchedState } from './dev-watch.js';
|
|
39
40
|
import { debug, debugEnabled, describeError, isMissing, reportError } from './diagnostics.js';
|
|
40
|
-
import { appEnvironment, readEnvFile } from './env-file.js';
|
|
41
|
+
import { appEnvironment, envFileWarnings, readEnvFile } from './env-file.js';
|
|
41
42
|
import { CliError } from './errors.js';
|
|
42
43
|
import { askLogWriter, CONTROL_HOST } from './log-writer-control.js';
|
|
43
44
|
import { redactNpmOutput } from './npm-client.js';
|
|
45
|
+
import { endLeftovers, holdTermination, killGroup, PROCESS_GROUPS } from './process-group.js';
|
|
44
46
|
import { ProjectInspector } from './project-inspector.js';
|
|
45
47
|
import { logFiles } from './rotating-log.js';
|
|
46
48
|
import { ViteHostProcess, viteHostScript } from './vite-host-process.js';
|
|
@@ -63,6 +65,8 @@ const paths = (root) => ({
|
|
|
63
65
|
pid: join(root, RUN, 'app.pid'),
|
|
64
66
|
log: join(root, RUN, 'app.log'),
|
|
65
67
|
writerLog: join(root, RUN, 'log-writer.log'),
|
|
68
|
+
/** D-0154: the outcome of the app's last configuration reload (RELOAD_STATUS_ENV). */
|
|
69
|
+
reloadStatus: join(root, RUN, 'reload.json'),
|
|
66
70
|
lock: join(root, RUN, 'start.lock'),
|
|
67
71
|
entry: join(root, ENTRY),
|
|
68
72
|
});
|
|
@@ -70,6 +74,145 @@ function daemonOnly() {
|
|
|
70
74
|
if (process.platform === 'win32')
|
|
71
75
|
throw new CliError('UNSUPPORTED_PLATFORM', 'Background start and stop need POSIX signals; on Windows use dokebi dev or dokebi start --foreground');
|
|
72
76
|
}
|
|
77
|
+
/** A start lock not refreshed for this long belongs to a start that died. */
|
|
78
|
+
const LOCK_STALE_MS = 60_000;
|
|
79
|
+
/** A start in progress refreshes its lock this often, however long it waits. */
|
|
80
|
+
const LOCK_REFRESH_MS = 10_000;
|
|
81
|
+
/** Breaking a stale lock takes milliseconds; a guard this old belongs to a process that died. */
|
|
82
|
+
const BREAK_STALE_MS = 10_000;
|
|
83
|
+
/**
|
|
84
|
+
* The start lock `.dokebi/run/start.lock` (R3). One start at a time holds it from before it
|
|
85
|
+
* reads the running state until its pid file is written, and `status`/`stop` take it to remove a
|
|
86
|
+
* stale pid file, so a pid file is only ever replaced or removed by the lock holder. It is
|
|
87
|
+
* created with `wx`; the holder refreshes it while it waits, so a lock older than LOCK_STALE_MS
|
|
88
|
+
* belongs to a start that died and is broken (see breakStale) before trying again.
|
|
89
|
+
*/
|
|
90
|
+
class StartLock {
|
|
91
|
+
file;
|
|
92
|
+
timer;
|
|
93
|
+
constructor(file) {
|
|
94
|
+
this.file = file;
|
|
95
|
+
this.timer = setInterval(() => {
|
|
96
|
+
const now = new Date();
|
|
97
|
+
utimes(file, now, now).catch((error) => {
|
|
98
|
+
reportError('START_LOCK_REFRESH_FAILED', 'Unable to refresh the start lock', error);
|
|
99
|
+
});
|
|
100
|
+
}, LOCK_REFRESH_MS);
|
|
101
|
+
this.timer.unref();
|
|
102
|
+
}
|
|
103
|
+
/** The lock, or undefined while another start holds it. */
|
|
104
|
+
static async acquire(file) {
|
|
105
|
+
for (let attempt = 0; attempt < 5; attempt++) {
|
|
106
|
+
try {
|
|
107
|
+
await writeFile(file, String(process.pid), { flag: 'wx' });
|
|
108
|
+
debug('Start lock acquired', { file: basename(file) });
|
|
109
|
+
return new StartLock(file);
|
|
110
|
+
}
|
|
111
|
+
catch (error) {
|
|
112
|
+
if (error.code !== 'EEXIST')
|
|
113
|
+
throw error;
|
|
114
|
+
}
|
|
115
|
+
let held;
|
|
116
|
+
try {
|
|
117
|
+
held = await stat(file);
|
|
118
|
+
}
|
|
119
|
+
catch (error) {
|
|
120
|
+
if (!isMissing(error))
|
|
121
|
+
throw error;
|
|
122
|
+
debug('Start lock released meanwhile; trying again', { attempt });
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
if (Date.now() - held.mtimeMs < LOCK_STALE_MS)
|
|
126
|
+
return undefined;
|
|
127
|
+
if (!(await StartLock.breakStale(file, held)))
|
|
128
|
+
return undefined;
|
|
129
|
+
}
|
|
130
|
+
debug('Start lock still contended after several attempts');
|
|
131
|
+
return undefined;
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* Removes the stale lock `held` under the guard `<lock>.break` (created with `wx`), so only one
|
|
135
|
+
* process breaks it and never a lock another start created after `held` was read (same inode
|
|
136
|
+
* and time, checked under the guard). False when another process is breaking it right now.
|
|
137
|
+
* A guard older than BREAK_STALE_MS is left by a process that died within these few steps.
|
|
138
|
+
*/
|
|
139
|
+
static async breakStale(file, held) {
|
|
140
|
+
const guard = `${file}.break`;
|
|
141
|
+
try {
|
|
142
|
+
await writeFile(guard, String(process.pid), { flag: 'wx' });
|
|
143
|
+
}
|
|
144
|
+
catch (error) {
|
|
145
|
+
if (error.code !== 'EEXIST')
|
|
146
|
+
throw error;
|
|
147
|
+
let since;
|
|
148
|
+
try {
|
|
149
|
+
since = Date.now() - (await stat(guard)).mtimeMs;
|
|
150
|
+
}
|
|
151
|
+
catch (cause) {
|
|
152
|
+
if (!isMissing(cause))
|
|
153
|
+
throw cause;
|
|
154
|
+
debug('The stale start lock was broken meanwhile; trying again');
|
|
155
|
+
return true;
|
|
156
|
+
}
|
|
157
|
+
if (since < BREAK_STALE_MS)
|
|
158
|
+
return false;
|
|
159
|
+
debug('Removing the guard of a lock break that died', { ageMs: Math.round(since) });
|
|
160
|
+
await rm(guard, { force: true });
|
|
161
|
+
return true;
|
|
162
|
+
}
|
|
163
|
+
try {
|
|
164
|
+
let now;
|
|
165
|
+
try {
|
|
166
|
+
now = await stat(file);
|
|
167
|
+
}
|
|
168
|
+
catch (error) {
|
|
169
|
+
if (!isMissing(error))
|
|
170
|
+
throw error;
|
|
171
|
+
return true;
|
|
172
|
+
}
|
|
173
|
+
if (now.ino === held.ino && now.mtimeMs === held.mtimeMs) {
|
|
174
|
+
await rm(file, { force: true });
|
|
175
|
+
debug('Removed a stale start lock', { ageMs: Math.round(Date.now() - now.mtimeMs) });
|
|
176
|
+
}
|
|
177
|
+
return true;
|
|
178
|
+
}
|
|
179
|
+
finally {
|
|
180
|
+
await rm(guard, { force: true });
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
async release() {
|
|
184
|
+
clearInterval(this.timer);
|
|
185
|
+
await rm(this.file, { force: true });
|
|
186
|
+
debug('Start lock released', { file: basename(this.file) });
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* Removes the pid file when it still holds `record` (R3): under the start lock, so a start that
|
|
191
|
+
* wrote a newer record meanwhile keeps it. While a start holds the lock it is left to that start,
|
|
192
|
+
* which replaces the file anyway. `locked` is true when the caller holds the lock already.
|
|
193
|
+
*/
|
|
194
|
+
async function removeRecord(root, record, locked = false) {
|
|
195
|
+
const files = paths(root);
|
|
196
|
+
const lock = locked ? undefined : await StartLock.acquire(files.lock);
|
|
197
|
+
if (!locked && !lock) {
|
|
198
|
+
debug('A start holds the lock; leaving the pid file to it', { pid: record.pid });
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
try {
|
|
202
|
+
const current = await readRecord(root);
|
|
203
|
+
if (current &&
|
|
204
|
+
(current.pid !== record.pid ||
|
|
205
|
+
current.startedAt !== record.startedAt ||
|
|
206
|
+
current.token !== record.token)) {
|
|
207
|
+
debug('The pid file names a newer start; keeping it', { pid: record.pid, now: current.pid });
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
await rm(files.pid, { force: true });
|
|
211
|
+
}
|
|
212
|
+
finally {
|
|
213
|
+
await lock?.release();
|
|
214
|
+
}
|
|
215
|
+
}
|
|
73
216
|
async function readRecord(root) {
|
|
74
217
|
let text;
|
|
75
218
|
try {
|
|
@@ -118,6 +261,9 @@ async function procInfo(pid) {
|
|
|
118
261
|
]);
|
|
119
262
|
// Fields after the parenthesised command name: state is field 3, starttime field 22.
|
|
120
263
|
const fields = stat.slice(stat.lastIndexOf(')') + 2).split(' ');
|
|
264
|
+
// A zombie has exited and only waits for its parent to reap it: gone for every caller here.
|
|
265
|
+
if (fields[0] === 'Z')
|
|
266
|
+
return undefined;
|
|
121
267
|
const ticks = Number(fields[19]);
|
|
122
268
|
const boot = Number(/^btime (\d+)$/m.exec(uptime)?.[1]);
|
|
123
269
|
if (!Number.isFinite(ticks) || !Number.isFinite(boot))
|
|
@@ -129,14 +275,22 @@ async function procInfo(pid) {
|
|
|
129
275
|
};
|
|
130
276
|
}
|
|
131
277
|
catch (error) {
|
|
132
|
-
// ENOENT: no such process. Anything else (permissions, a changed /proc) is a failure.
|
|
133
278
|
if (error instanceof CliError)
|
|
134
279
|
throw error;
|
|
135
|
-
if (
|
|
280
|
+
if (procGone(error))
|
|
136
281
|
return undefined;
|
|
137
282
|
throw processInfoFailed(pid, '/proc', error);
|
|
138
283
|
}
|
|
139
284
|
}
|
|
285
|
+
/**
|
|
286
|
+
* Whether a /proc read failed because the process is gone: ENOENT (no /proc/<pid>), or ESRCH,
|
|
287
|
+
* which Linux returns when the process exits while one of its files is open. Anything else
|
|
288
|
+
* (permissions, a changed /proc) is a failure. ESRCH was found by the v0.4.1 tag pipeline: an
|
|
289
|
+
* app that stopped during `stop`'s check ended as PROCESS_INFO_FAILED.
|
|
290
|
+
*/
|
|
291
|
+
export function procGone(error) {
|
|
292
|
+
return isMissing(error) || error?.code === 'ESRCH';
|
|
293
|
+
}
|
|
140
294
|
const readProcess = async (pid) => {
|
|
141
295
|
if (process.platform === 'linux')
|
|
142
296
|
return procInfo(pid);
|
|
@@ -210,7 +364,11 @@ export async function status(root,
|
|
|
210
364
|
read = readProcess,
|
|
211
365
|
/** Tests replace how a process is signalled; the CLI uses process.kill. */
|
|
212
366
|
kill = signalProcess) {
|
|
213
|
-
|
|
367
|
+
return statusOf(root, read, kill, false);
|
|
368
|
+
}
|
|
369
|
+
/** `status`; `locked` when the caller (start) holds the start lock. */
|
|
370
|
+
async function statusOf(root, read, kill, locked) {
|
|
371
|
+
const { log } = paths(root);
|
|
214
372
|
const record = await readRecord(root);
|
|
215
373
|
const logs = await logInfo(root);
|
|
216
374
|
if (!record)
|
|
@@ -228,7 +386,7 @@ kill = signalProcess) {
|
|
|
228
386
|
proof = found;
|
|
229
387
|
}
|
|
230
388
|
if (!info && proof === 'process') {
|
|
231
|
-
await
|
|
389
|
+
await removeRecord(root, record, locked);
|
|
232
390
|
return {
|
|
233
391
|
running: false,
|
|
234
392
|
stale: true,
|
|
@@ -280,15 +438,18 @@ function pidState(pid, kill) {
|
|
|
280
438
|
return 'unknown';
|
|
281
439
|
}
|
|
282
440
|
}
|
|
283
|
-
/**
|
|
441
|
+
/**
|
|
442
|
+
* The web address the settings file configures (DOKEBI_CONFIG, else dokebi.yaml: the one the app
|
|
443
|
+
* reads, M50), for pid files that recorded none.
|
|
444
|
+
*/
|
|
284
445
|
async function configuredUrl(root) {
|
|
285
446
|
let source;
|
|
286
447
|
try {
|
|
287
|
-
source = await
|
|
448
|
+
source = (await readProjectSettings(root)).source;
|
|
288
449
|
}
|
|
289
450
|
catch (error) {
|
|
290
451
|
if (!isMissing(error))
|
|
291
|
-
reportError('CONFIG_READ_FAILED',
|
|
452
|
+
reportError('CONFIG_READ_FAILED', 'Unable to read the settings file of the project', error);
|
|
292
453
|
return undefined;
|
|
293
454
|
}
|
|
294
455
|
let web;
|
|
@@ -449,7 +610,7 @@ async function withoutProcessInfo(root, record, failure, kill) {
|
|
|
449
610
|
},
|
|
450
611
|
});
|
|
451
612
|
}
|
|
452
|
-
async function checkProject(root) {
|
|
613
|
+
export async function checkProject(root) {
|
|
453
614
|
const { entry } = paths(root);
|
|
454
615
|
if (!existsSync(entry))
|
|
455
616
|
throw new CliError('ENTRY_MISSING', `${ENTRY} does not exist`);
|
|
@@ -510,6 +671,42 @@ async function tail(file, lines, current) {
|
|
|
510
671
|
}
|
|
511
672
|
return result.length ? `${result.join('\n')}\n` : '';
|
|
512
673
|
}
|
|
674
|
+
/** The line the log writer writes before each start (log-writer.ts). */
|
|
675
|
+
const START_MARK = '--- dokebi start ';
|
|
676
|
+
/**
|
|
677
|
+
* R1 T4 review: why a start failed, from the app log of this start only (after its last start
|
|
678
|
+
* mark): the first record with an error, as `<code>: <first line>` and, when the error has
|
|
679
|
+
* causes, `… <its last Caused by line>`. A START_FAILED said only "exited with code 1", and its
|
|
680
|
+
* tail began with an earlier start's lines. Undefined when no record says.
|
|
681
|
+
*/
|
|
682
|
+
export function startFailureCause(lines) {
|
|
683
|
+
const from = lines.findLastIndex((line) => line.startsWith(START_MARK));
|
|
684
|
+
for (const line of lines.slice(from + 1)) {
|
|
685
|
+
// Records are JSON lines; a stack line or a plain console line is not one.
|
|
686
|
+
if (!line.startsWith('{'))
|
|
687
|
+
continue;
|
|
688
|
+
let record;
|
|
689
|
+
try {
|
|
690
|
+
record = JSON.parse(line);
|
|
691
|
+
}
|
|
692
|
+
catch (error) {
|
|
693
|
+
debug('A log line that starts like a record is not JSON', { error: describeError(error) });
|
|
694
|
+
continue;
|
|
695
|
+
}
|
|
696
|
+
if (record === null || typeof record !== 'object')
|
|
697
|
+
continue;
|
|
698
|
+
const error = typeof record.error === 'string' ? record.error : undefined;
|
|
699
|
+
if (error === undefined && record.level !== 'error')
|
|
700
|
+
continue;
|
|
701
|
+
const text = error ?? String(record.message ?? '');
|
|
702
|
+
const first = text.split('\n')[0] ?? '';
|
|
703
|
+
const causes = text.split('\n').filter((each) => each.startsWith('Caused by: '));
|
|
704
|
+
const deepest = causes.at(-1)?.slice('Caused by: '.length);
|
|
705
|
+
const code = typeof record.code === 'string' ? `${record.code}: ` : '';
|
|
706
|
+
return `${code}${first}${deepest ? ` … ${deepest}` : ''}`.slice(0, 600);
|
|
707
|
+
}
|
|
708
|
+
return undefined;
|
|
709
|
+
}
|
|
513
710
|
/** Rejects rotation settings outside LOG_LIMITS; bytes below 1 MB are allowed for tests. */
|
|
514
711
|
function logSettings(options) {
|
|
515
712
|
const maxBytes = options.logMaxBytes ?? LOG_LIMITS.maxMb.default * MB;
|
|
@@ -528,48 +725,59 @@ check = checkProject) {
|
|
|
528
725
|
const waitMs = options.waitMs ?? 10_000;
|
|
529
726
|
const files = paths(root);
|
|
530
727
|
const began = performance.now();
|
|
531
|
-
const current = await status(root);
|
|
532
|
-
if (current.running)
|
|
533
|
-
throw new CliError('ALREADY_RUNNING', `Already running (pid ${current.pid})`, {
|
|
534
|
-
details: current,
|
|
535
|
-
});
|
|
536
|
-
const entry = await check(root);
|
|
537
|
-
// The project's .env under the CLI's environment (D-0073); a malformed file stops here.
|
|
538
|
-
const token = randomBytes(24).toString('base64url');
|
|
539
|
-
const env = appEnvironment(root, 'production', {
|
|
540
|
-
...options.env,
|
|
541
|
-
[INSTANCE_TOKEN_ENV]: token,
|
|
542
|
-
});
|
|
543
728
|
await mkdir(files.run, { recursive: true });
|
|
544
729
|
await writeFile(join(files.run, '.gitignore'), '*\n');
|
|
545
|
-
//
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
//
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
})).mtimeMs;
|
|
565
|
-
if (age < 60_000)
|
|
566
|
-
throw new CliError('START_IN_PROGRESS', 'Another dokebi start is running for this project');
|
|
567
|
-
debug('Replacing a stale start lock', { ageMs: age });
|
|
568
|
-
await writeFile(files.lock, String(process.pid));
|
|
569
|
-
}
|
|
730
|
+
// One start at a time (R3): the lock is taken before the running state is read, so a start
|
|
731
|
+
// that waited (in its checks) while another start finished sees that start's app.
|
|
732
|
+
const lock = await StartLock.acquire(files.lock);
|
|
733
|
+
if (!lock)
|
|
734
|
+
throw new CliError('START_IN_PROGRESS', 'Another dokebi start is running for this project');
|
|
735
|
+
// R4CC-1: a SIGINT, SIGTERM or SIGHUP while the start runs (Ctrl+C, an agent's command time
|
|
736
|
+
// limit during verify's start step) ends what it started and its lock first, then the command
|
|
737
|
+
// ends as the signal says. Before, the app and its writer ran on with a stale start lock, and
|
|
738
|
+
// the next verify refused with ALREADY_RUNNING.
|
|
739
|
+
const termination = holdTermination();
|
|
740
|
+
const interrupted = () => {
|
|
741
|
+
const signal = termination.signal();
|
|
742
|
+
if (signal)
|
|
743
|
+
throw new CliError('INTERRUPTED', `dokebi start was interrupted (${signal}); nothing it started is left running`, { details: { signal } });
|
|
744
|
+
};
|
|
745
|
+
/** This start wrote the pid file (removed again when it is interrupted). */
|
|
746
|
+
let wrote = false;
|
|
747
|
+
/** The start returned: the app is the caller's from here on. */
|
|
748
|
+
let returned = false;
|
|
570
749
|
const warnings = [];
|
|
571
750
|
let writer;
|
|
751
|
+
/** The writer's process group while a failure must not leave it (and the app) running. */
|
|
752
|
+
let leftover;
|
|
753
|
+
/** The app's own process group (the log writer spawns it detached), once it was spawned. */
|
|
754
|
+
let appGroup;
|
|
572
755
|
try {
|
|
756
|
+
const current = await statusOf(root, readProcess, signalProcess, true);
|
|
757
|
+
if (current.running)
|
|
758
|
+
throw new CliError('ALREADY_RUNNING', `Already running (pid ${current.pid})`, {
|
|
759
|
+
details: current,
|
|
760
|
+
});
|
|
761
|
+
const entry = await check(root);
|
|
762
|
+
interrupted();
|
|
763
|
+
// The project's .env under the CLI's environment (D-0073); a malformed file stops here.
|
|
764
|
+
const token = randomBytes(24).toString('base64url');
|
|
765
|
+
const env = appEnvironment(root, 'production', {
|
|
766
|
+
...options.env,
|
|
767
|
+
[INSTANCE_TOKEN_ENV]: token,
|
|
768
|
+
// D-0154: the app writes the outcome of each configuration reload here (dokebi reload).
|
|
769
|
+
[RELOAD_STATUS_ENV]: files.reloadStatus,
|
|
770
|
+
});
|
|
771
|
+
// R4CC-7: what .env sets that the app does not get (DOKEBI_CONFIG) is said, never dropped
|
|
772
|
+
// silently.
|
|
773
|
+
for (const warning of envFileWarnings(root))
|
|
774
|
+
warnings.push(`${warning.code} (${warning.path}): ${warning.hint}`);
|
|
775
|
+
// Lines this start writes begin here (the web listening line is parsed from them).
|
|
776
|
+
const logOffset = await stat(files.log).then((info) => info.size, (error) => {
|
|
777
|
+
if (!isMissing(error))
|
|
778
|
+
throw error;
|
|
779
|
+
return 0;
|
|
780
|
+
});
|
|
573
781
|
// The writer's own diagnostics (it has no app logger) go to log-writer.log.
|
|
574
782
|
const writerLog = await open(files.writerLog, 'a');
|
|
575
783
|
try {
|
|
@@ -593,13 +801,16 @@ check = checkProject) {
|
|
|
593
801
|
const writerPid = writer.pid;
|
|
594
802
|
if (writerPid === undefined)
|
|
595
803
|
throw new CliError('START_FAILED', 'The log writer process could not be spawned');
|
|
804
|
+
leftover = writerPid;
|
|
596
805
|
const state = {};
|
|
597
806
|
writer.on('message', (message) => {
|
|
598
807
|
// The writer sends its control port before it spawns the app (IPC keeps the order).
|
|
599
808
|
if (typeof message?.control === 'number')
|
|
600
809
|
state.control = message.control;
|
|
601
|
-
if (typeof message?.spawned === 'number')
|
|
810
|
+
if (typeof message?.spawned === 'number') {
|
|
602
811
|
state.pid = message.spawned;
|
|
812
|
+
appGroup = message.spawned;
|
|
813
|
+
}
|
|
603
814
|
if (message?.ready)
|
|
604
815
|
state.ready = true;
|
|
605
816
|
if (message && 'exited' in message)
|
|
@@ -617,11 +828,15 @@ check = checkProject) {
|
|
|
617
828
|
debug('Log writer spawned; waiting for the app', { writer: writerPid, waitMs, maxBytes, keep });
|
|
618
829
|
const failure = async (message) => {
|
|
619
830
|
await rm(files.pid, { force: true });
|
|
620
|
-
|
|
831
|
+
// This start's lines only (after its mark), and the cause in the message itself.
|
|
832
|
+
const recent = (await tail(files.log, 200)).split('\n');
|
|
833
|
+
const mark = recent.findLastIndex((line) => line.startsWith(START_MARK));
|
|
834
|
+
const cause = startFailureCause(recent);
|
|
835
|
+
return new CliError('START_FAILED', cause ? `${message}: ${redactNpmOutput(cause)}` : message, {
|
|
621
836
|
details: {
|
|
622
837
|
log: files.log,
|
|
623
838
|
// App output in a result: credentials masked like npm output (logs prints it as is).
|
|
624
|
-
tail: redactNpmOutput(
|
|
839
|
+
tail: redactNpmOutput(recent.slice(Math.max(mark + 1, recent.length - 31)).join('\n')),
|
|
625
840
|
logWriterLog: redactNpmOutput((await lastLines(files.writerLog, 30)).join('\n')),
|
|
626
841
|
},
|
|
627
842
|
});
|
|
@@ -631,11 +846,13 @@ check = checkProject) {
|
|
|
631
846
|
await rm(`${files.pid}.tmp`, { force: true });
|
|
632
847
|
await writeFile(`${files.pid}.tmp`, `${JSON.stringify(record)}\n`, { mode: 0o600 });
|
|
633
848
|
await rename(`${files.pid}.tmp`, files.pid);
|
|
849
|
+
wrote = true;
|
|
634
850
|
};
|
|
635
851
|
const deadline = performance.now() + waitMs;
|
|
636
852
|
let recorded;
|
|
637
853
|
while (performance.now() < deadline) {
|
|
638
854
|
await delay(50);
|
|
855
|
+
interrupted();
|
|
639
856
|
if (state.pid !== undefined && recorded === undefined) {
|
|
640
857
|
recorded = {
|
|
641
858
|
pid: state.pid,
|
|
@@ -651,27 +868,29 @@ check = checkProject) {
|
|
|
651
868
|
await save(recorded);
|
|
652
869
|
debug('App process spawned; waiting for the start line', { pid: state.pid });
|
|
653
870
|
}
|
|
654
|
-
if (state.exited !== undefined)
|
|
871
|
+
if (state.exited !== undefined) {
|
|
872
|
+
// Nothing is left to stop: the writer writes the app's last lines and ends by itself.
|
|
873
|
+
leftover = undefined;
|
|
655
874
|
throw await failure(`The application exited with ${state.exited}`);
|
|
875
|
+
}
|
|
656
876
|
if (state.failed !== undefined)
|
|
657
877
|
throw await failure(`The application could not be started: ${state.failed}`);
|
|
658
878
|
if (state.ready && recorded !== undefined)
|
|
659
879
|
break;
|
|
660
880
|
}
|
|
661
|
-
if (recorded === undefined)
|
|
662
|
-
// The writer never started the app: do not leave it (or a late app) behind. The writer
|
|
663
|
-
// leads its own process group (detached), so the group signal reaches both.
|
|
664
|
-
process.kill(-writerPid, 'SIGKILL');
|
|
881
|
+
if (recorded === undefined)
|
|
665
882
|
throw await failure(`The log writer did not start the application within ${waitMs} ms`);
|
|
666
|
-
}
|
|
667
883
|
const ready = state.ready === true;
|
|
668
884
|
if (!ready)
|
|
669
|
-
warnings.push(`"${STARTED}" was not logged within ${waitMs} ms; the process is running but may not be ready`);
|
|
670
|
-
const listen = ready ? await
|
|
885
|
+
warnings.push(`"${STARTED}" was not logged within ${waitMs} ms; the process is running but may not be ready (a src/main.ts of your own: send process.send?.({ dokebi: 'started' }) after app.start(), as the generated one does, so a logging.level above info still shows the start)`);
|
|
886
|
+
const listen = ready ? await listeningAfterStart(root, files.log, logOffset, deadline) : [];
|
|
671
887
|
// Where stop and status ask for the instance proof when ps is unavailable (D-0073): the
|
|
672
888
|
// listening address (the proof does not depend on the Host header).
|
|
673
889
|
if (listen.length)
|
|
674
890
|
await save({ ...recorded, urls: listen });
|
|
891
|
+
const urls = appUrls(listen, await appHostsOrNone(root));
|
|
892
|
+
interrupted();
|
|
893
|
+
returned = true;
|
|
675
894
|
return {
|
|
676
895
|
pid: recorded.pid,
|
|
677
896
|
log: files.log,
|
|
@@ -679,18 +898,45 @@ check = checkProject) {
|
|
|
679
898
|
logMaxBytes: maxBytes,
|
|
680
899
|
logFiles: keep,
|
|
681
900
|
ready,
|
|
682
|
-
urls
|
|
901
|
+
urls,
|
|
683
902
|
listen,
|
|
684
903
|
ms: Math.round(performance.now() - began),
|
|
685
904
|
warnings,
|
|
686
905
|
};
|
|
687
906
|
}
|
|
907
|
+
catch (error) {
|
|
908
|
+
// A start that fails once the writer runs leaves neither it nor the app behind: the writer
|
|
909
|
+
// never started the app in time (or a late app), the writer died (the app it spawned would
|
|
910
|
+
// keep running and hold the port, and verify stops only what start returned), or the pid
|
|
911
|
+
// file could not be written. The writer leads its own process group (detached) and the app
|
|
912
|
+
// leads one below it (with what the app started), so both groups are signalled.
|
|
913
|
+
if (leftover !== undefined) {
|
|
914
|
+
if (killGroup(leftover, 'SIGKILL'))
|
|
915
|
+
debug('Killed the log writer group of a failed start', { writer: leftover });
|
|
916
|
+
if (appGroup !== undefined && killGroup(appGroup, 'SIGKILL'))
|
|
917
|
+
debug('Killed the app group of a failed start', { pid: appGroup });
|
|
918
|
+
}
|
|
919
|
+
// An interrupted start leaves no pid file naming what it ended (a failed one removed it).
|
|
920
|
+
if (wrote && termination.signal())
|
|
921
|
+
await rm(files.pid, { force: true });
|
|
922
|
+
throw error;
|
|
923
|
+
}
|
|
688
924
|
finally {
|
|
689
925
|
// The writer and the app outlive this command.
|
|
690
926
|
if (writer?.connected)
|
|
691
927
|
writer.disconnect();
|
|
692
928
|
writer?.unref();
|
|
693
|
-
await
|
|
929
|
+
await lock.release();
|
|
930
|
+
// A signal during these last steps of a start that returned: this command ends with it (just
|
|
931
|
+
// below), and nobody would stop the app, so it is ended like an interrupted start.
|
|
932
|
+
if (returned && termination.signal() && leftover !== undefined) {
|
|
933
|
+
killGroup(leftover, 'SIGKILL');
|
|
934
|
+
if (appGroup !== undefined)
|
|
935
|
+
killGroup(appGroup, 'SIGKILL');
|
|
936
|
+
await rm(files.pid, { force: true });
|
|
937
|
+
}
|
|
938
|
+
// A signal that came meanwhile ends this command now, as it would have without the hold.
|
|
939
|
+
termination.release();
|
|
694
940
|
}
|
|
695
941
|
}
|
|
696
942
|
/**
|
|
@@ -745,6 +991,38 @@ export async function appHostsOrNone(root) {
|
|
|
745
991
|
return { hosted: false, domains: [], answersUnknown: false };
|
|
746
992
|
}
|
|
747
993
|
}
|
|
994
|
+
/**
|
|
995
|
+
* The listening URLs once the app said it started. The start message comes over IPC, the
|
|
996
|
+
* listening line through the log, a path with more hops (the logger's worker thread; a cluster's
|
|
997
|
+
* primary relaying whole lines, M51): the line can reach app.log just after the message. So with
|
|
998
|
+
* @dokebi/web in the project the log is read again until the line is there or the start's own
|
|
999
|
+
* deadline passes; a project without web logs no such line and is read once.
|
|
1000
|
+
*/
|
|
1001
|
+
async function listeningAfterStart(root, log, offset, deadline) {
|
|
1002
|
+
let listen = await startedUrls(log, offset);
|
|
1003
|
+
if (listen.length || !hasWebDependency(root))
|
|
1004
|
+
return listen;
|
|
1005
|
+
while (!listen.length && performance.now() < deadline) {
|
|
1006
|
+
await delay(25);
|
|
1007
|
+
listen = await startedUrls(log, offset);
|
|
1008
|
+
}
|
|
1009
|
+
if (!listen.length)
|
|
1010
|
+
debug('No web listening line in the log before the start deadline', { log });
|
|
1011
|
+
return listen;
|
|
1012
|
+
}
|
|
1013
|
+
/** Whether package.json depends on @dokebi/web (a malformed one counts as no). */
|
|
1014
|
+
function hasWebDependency(root) {
|
|
1015
|
+
try {
|
|
1016
|
+
const manifest = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8'));
|
|
1017
|
+
return manifest.dependencies?.['@dokebi/web'] !== undefined;
|
|
1018
|
+
}
|
|
1019
|
+
catch (error) {
|
|
1020
|
+
debug('package.json not read while waiting for the listening line', {
|
|
1021
|
+
error: describeError(error),
|
|
1022
|
+
});
|
|
1023
|
+
return false;
|
|
1024
|
+
}
|
|
1025
|
+
}
|
|
748
1026
|
async function startedUrls(log, offset) {
|
|
749
1027
|
try {
|
|
750
1028
|
const bytes = await readFile(log);
|
|
@@ -763,7 +1041,6 @@ read = readProcess,
|
|
|
763
1041
|
/** Tests replace how a process is signalled; the CLI uses process.kill. */
|
|
764
1042
|
kill = signalProcess) {
|
|
765
1043
|
daemonOnly();
|
|
766
|
-
const files = paths(root);
|
|
767
1044
|
const record = await readRecord(root);
|
|
768
1045
|
if (!record)
|
|
769
1046
|
return { stopped: false };
|
|
@@ -785,7 +1062,7 @@ kill = signalProcess) {
|
|
|
785
1062
|
}
|
|
786
1063
|
if (!found) {
|
|
787
1064
|
debug('Removing a stale pid file', { pid: record.pid });
|
|
788
|
-
await
|
|
1065
|
+
await removeRecord(root, record);
|
|
789
1066
|
return { stopped: false, pid: record.pid, stale: true };
|
|
790
1067
|
}
|
|
791
1068
|
if (proof === 'log-writer')
|
|
@@ -829,7 +1106,7 @@ kill = signalProcess) {
|
|
|
829
1106
|
if (!(await gone(app, 5000)))
|
|
830
1107
|
throw new CliError('STOP_TIMEOUT', `pid ${record.pid} survived SIGKILL`);
|
|
831
1108
|
}
|
|
832
|
-
await
|
|
1109
|
+
await removeRecord(root, record);
|
|
833
1110
|
const warnings = [];
|
|
834
1111
|
// The app is stopped and its pid file removed: a failure to read the writer is a warning.
|
|
835
1112
|
const writerGone = await gone(writer, 5000).catch((error) => {
|
|
@@ -857,7 +1134,6 @@ kill = signalProcess) {
|
|
|
857
1134
|
* the connection). An unreachable or unproven writer leaves everything as it was.
|
|
858
1135
|
*/
|
|
859
1136
|
async function stopThroughWriter(root, record, timeoutMs, force, proof, refused) {
|
|
860
|
-
const files = paths(root);
|
|
861
1137
|
const began = performance.now();
|
|
862
1138
|
const asked = await askWriter(record, { op: 'stop', timeoutMs, force });
|
|
863
1139
|
if (!asked.reached)
|
|
@@ -877,7 +1153,7 @@ async function stopThroughWriter(root, record, timeoutMs, force, proof, refused)
|
|
|
877
1153
|
? reply.message
|
|
878
1154
|
: `The log writer could not stop pid ${record.pid} (${String(reply.code)})`);
|
|
879
1155
|
}
|
|
880
|
-
await
|
|
1156
|
+
await removeRecord(root, record);
|
|
881
1157
|
if (reply.stopped !== true) {
|
|
882
1158
|
debug('The log writer reports that the app had already exited', { pid: record.pid });
|
|
883
1159
|
await asked.closed(0);
|
|
@@ -923,11 +1199,11 @@ function pidExists(pid, kill = signalProcess) {
|
|
|
923
1199
|
*/
|
|
924
1200
|
export async function stopStarted(root, started, timeoutMs = 30_000, force = false) {
|
|
925
1201
|
daemonOnly();
|
|
926
|
-
const files = paths(root);
|
|
927
1202
|
const forget = async () => {
|
|
928
1203
|
// Only the pid file of this start: another start may have replaced it meanwhile.
|
|
929
|
-
|
|
930
|
-
|
|
1204
|
+
const current = await readRecord(root);
|
|
1205
|
+
if (current?.pid === started.pid)
|
|
1206
|
+
await removeRecord(root, current);
|
|
931
1207
|
};
|
|
932
1208
|
const gone = async (pid, limit) => {
|
|
933
1209
|
const until = performance.now() + limit;
|
|
@@ -977,7 +1253,9 @@ export async function stopStarted(root, started, timeoutMs = 30_000, force = fal
|
|
|
977
1253
|
* it finishes its file, then reads each rotated file newer than it (found by inode, oldest
|
|
978
1254
|
* first) before following the new app.log, so several rotations between two polls skip no line.
|
|
979
1255
|
*/
|
|
980
|
-
export async function logs(root, lines = 100, follow = false,
|
|
1256
|
+
export async function logs(root, lines = 100, follow = false,
|
|
1257
|
+
// dokebi-check: allow console-output dokebi logs prints the app log lines to the terminal
|
|
1258
|
+
write = (text) => process.stdout.write(text), signal) {
|
|
981
1259
|
const file = paths(root).log;
|
|
982
1260
|
if (!follow) {
|
|
983
1261
|
if (!existsSync(file))
|
|
@@ -1194,7 +1472,7 @@ export async function foreground(root) {
|
|
|
1194
1472
|
}
|
|
1195
1473
|
}
|
|
1196
1474
|
/** The installed version of a package, looked up like Node does (node_modules up the tree). */
|
|
1197
|
-
function installedVersion(root, name) {
|
|
1475
|
+
export function installedVersion(root, name) {
|
|
1198
1476
|
for (let directory = root;; directory = dirname(directory)) {
|
|
1199
1477
|
try {
|
|
1200
1478
|
const { version } = JSON.parse(readFileSync(join(directory, 'node_modules', name, 'package.json'), 'utf8'));
|
|
@@ -1220,16 +1498,87 @@ function inertiaDevServer(root) {
|
|
|
1220
1498
|
}
|
|
1221
1499
|
/** Files the Vite dev server owns: a change reaches the browser by HMR, not by a restart. */
|
|
1222
1500
|
const VIEW_FILE = /(^|[\\/])views[\\/].*\.(js|mjs|ts)$|\.(vue|css|scss|sass|less)$/;
|
|
1501
|
+
let devReloadId = 0;
|
|
1502
|
+
/**
|
|
1503
|
+
* D-0154: asks the app `dokebi dev` runs to read dokebi.yaml again (through the IPC channel of
|
|
1504
|
+
* its preload, dev-guard.ts). Resolves with its answer, or undefined when it cannot be asked,
|
|
1505
|
+
* exits or does not answer within `timeoutMs`.
|
|
1506
|
+
*/
|
|
1507
|
+
function askDevReload(app, timeoutMs) {
|
|
1508
|
+
if (!app.connected || app.exitCode !== null || app.signalCode !== null)
|
|
1509
|
+
return Promise.resolve(undefined);
|
|
1510
|
+
const id = ++devReloadId;
|
|
1511
|
+
return new Promise((resolve) => {
|
|
1512
|
+
const finish = (answer) => {
|
|
1513
|
+
clearTimeout(timer);
|
|
1514
|
+
app.off('message', onMessage);
|
|
1515
|
+
app.off('exit', onExit);
|
|
1516
|
+
resolve(answer);
|
|
1517
|
+
};
|
|
1518
|
+
const onMessage = (message) => {
|
|
1519
|
+
const reply = message;
|
|
1520
|
+
if (reply?.dokebi === 'dev-reload-result' &&
|
|
1521
|
+
reply.id === id &&
|
|
1522
|
+
typeof reply.outcome === 'string')
|
|
1523
|
+
finish(reply);
|
|
1524
|
+
};
|
|
1525
|
+
const onExit = () => finish(undefined);
|
|
1526
|
+
const timer = setTimeout(() => finish(undefined), timeoutMs);
|
|
1527
|
+
app.on('message', onMessage);
|
|
1528
|
+
app.once('exit', onExit);
|
|
1529
|
+
try {
|
|
1530
|
+
app.send({ dokebi: 'dev-reload', id }, (error) => {
|
|
1531
|
+
if (error) {
|
|
1532
|
+
debug('dev: the reload request could not be sent; restarting instead', {
|
|
1533
|
+
error: describeError(error),
|
|
1534
|
+
});
|
|
1535
|
+
finish(undefined);
|
|
1536
|
+
}
|
|
1537
|
+
});
|
|
1538
|
+
}
|
|
1539
|
+
catch (error) {
|
|
1540
|
+
debug('dev: the reload request could not be sent; restarting instead', {
|
|
1541
|
+
error: describeError(error),
|
|
1542
|
+
});
|
|
1543
|
+
finish(undefined);
|
|
1544
|
+
}
|
|
1545
|
+
});
|
|
1546
|
+
}
|
|
1547
|
+
/** How long dev waits for a reload answer: the app's prepareTimeoutMs (dokebi.yaml) and 5 s. */
|
|
1548
|
+
async function devReloadTimeout(root) {
|
|
1549
|
+
try {
|
|
1550
|
+
const options = parseProjectConfig((await readProjectSettings(root)).source).options;
|
|
1551
|
+
const reload = options.reload;
|
|
1552
|
+
const ms = reload?.prepareTimeoutMs;
|
|
1553
|
+
return (typeof ms === 'number' && Number.isSafeInteger(ms) && ms > 0 ? ms : 30_000) + 5000;
|
|
1554
|
+
}
|
|
1555
|
+
catch (error) {
|
|
1556
|
+
// A broken dokebi.yaml: the app answers `invalid` at once; the default bounds the wait.
|
|
1557
|
+
debug('dev: dokebi.yaml not read for the reload timeout', { error: describeError(error) });
|
|
1558
|
+
return 35_000;
|
|
1559
|
+
}
|
|
1560
|
+
}
|
|
1223
1561
|
/**
|
|
1224
1562
|
* Development loop: runs the app, restarts it (SIGTERM, drain, start) 300 ms after a change in
|
|
1225
|
-
* src/, dokebi.yaml or .env
|
|
1226
|
-
*
|
|
1227
|
-
*
|
|
1228
|
-
*
|
|
1563
|
+
* src/, dokebi.yaml or .env (a path whose state differs from the recorded one, see dev-watch.ts).
|
|
1564
|
+
* D-0154: a change of dokebi.yaml alone is first offered to the running app: when every changed
|
|
1565
|
+
* option is reloadable it applies them and keeps running; an invalid file keeps the app as it is;
|
|
1566
|
+
* anything else (an option that needs a restart, an app without reloads) restarts it.
|
|
1567
|
+
* With a Vite config, Inertia views come from the Vite dev server (HMR, D-0045) when
|
|
1568
|
+
* template-inertia supports it, otherwise from watch builds that restart the app. With
|
|
1569
|
+
* template-inertia 0.4.0 that Vite is one host process beside the app, shared by every cluster
|
|
1570
|
+
* worker and kept across app restarts (D-0045 addendum 3); older ones start it inside the app.
|
|
1229
1571
|
* The first Ctrl+C drains everything; a second one kills.
|
|
1230
1572
|
*/
|
|
1231
1573
|
export async function dev(root, say) {
|
|
1232
1574
|
const entry = await checkProject(root);
|
|
1575
|
+
// R4CC-7: a DOKEBI_CONFIG line of .env is not passed to the app; dev says so once.
|
|
1576
|
+
for (const warning of envFileWarnings(root))
|
|
1577
|
+
say(`${warning.code} (${warning.path}): ${warning.hint}`);
|
|
1578
|
+
// M50: the settings file the app reads (DOKEBI_CONFIG, else dokebi.yaml) is the one watched
|
|
1579
|
+
// for a reload and named in what dev says.
|
|
1580
|
+
const settings = await configTarget(root);
|
|
1581
|
+
const settingsName = settings.file;
|
|
1233
1582
|
// The project's .env (D-0073), read again before every (re)start: a change restarts the app
|
|
1234
1583
|
// with the new values. A malformed file fails here and, later, fails that restart only.
|
|
1235
1584
|
let fileEnv = readEnvFile(root);
|
|
@@ -1248,10 +1597,14 @@ export async function dev(root, say) {
|
|
|
1248
1597
|
: `listening on ${configured}; the app answers only the Host names of its domains (none is a loopback name such as localhost)`);
|
|
1249
1598
|
}
|
|
1250
1599
|
const guard = new URL('./dev-guard.js', import.meta.url).href;
|
|
1251
|
-
// stdio fd 3 is the IPC channel the guard watches.
|
|
1252
|
-
|
|
1600
|
+
// stdio fd 3 is the IPC channel the guard watches. `group`: the app leads a process group of
|
|
1601
|
+
// its own (POSIX), so what it started and left running when it exits (a restart, a crash) is
|
|
1602
|
+
// found there and ended instead of holding a port against the next start. Ctrl+C reaches it
|
|
1603
|
+
// through dev (SIGTERM), and the guard still ends it when dev dies.
|
|
1604
|
+
const child = (args, extra = {}, group = false) => spawn(process.execPath, ['--import', guard, ...args], {
|
|
1253
1605
|
cwd: root,
|
|
1254
1606
|
stdio: ['inherit', 'inherit', 'inherit', 'ipc'],
|
|
1607
|
+
detached: group && PROCESS_GROUPS,
|
|
1255
1608
|
// DOKEBI_DEV: a cluster app runs one worker (@dokebi/cluster, DOKEBI_DEV_WORKERS for more).
|
|
1256
1609
|
env: appEnvironment(root, 'development', { DOKEBI_DEV: '1', ...(hot ? { DOKEBI_VITE_DEV: '1' } : {}), ...extra }, fileEnv),
|
|
1257
1610
|
});
|
|
@@ -1277,24 +1630,52 @@ export async function dev(root, say) {
|
|
|
1277
1630
|
return;
|
|
1278
1631
|
extra = { DOKEBI_VITE_DEV_URL: address.url, DOKEBI_VITE_DEV_KEY: address.key };
|
|
1279
1632
|
}
|
|
1280
|
-
app = child([entry], extra);
|
|
1633
|
+
app = child([entry], extra, true);
|
|
1281
1634
|
const current = app;
|
|
1282
1635
|
current.once('exit', (code, signal) => {
|
|
1283
1636
|
if (app === current && !exiting)
|
|
1284
1637
|
say(`app exited (${signal ?? code}); waiting for a change to restart`);
|
|
1638
|
+
if (current.pid !== undefined)
|
|
1639
|
+
void leftovers(current.pid);
|
|
1285
1640
|
});
|
|
1286
1641
|
};
|
|
1287
|
-
|
|
1288
|
-
|
|
1642
|
+
/** Ends what an exited app left in its process group, and says so. */
|
|
1643
|
+
const ended = new Map();
|
|
1644
|
+
const leftovers = (pid) => {
|
|
1645
|
+
let done = ended.get(pid);
|
|
1646
|
+
if (!done) {
|
|
1647
|
+
done = endLeftovers(pid, 2000).then((how) => {
|
|
1648
|
+
if (how !== 'none')
|
|
1649
|
+
say(`processes the app started were still running after it exited; ended them (${how === 'killed' ? 'SIGKILL' : 'SIGTERM'})`);
|
|
1650
|
+
});
|
|
1651
|
+
ended.set(pid, done);
|
|
1652
|
+
}
|
|
1653
|
+
return done;
|
|
1654
|
+
};
|
|
1655
|
+
/**
|
|
1656
|
+
* R4CC-4: apps dev is stopping (a restart drains the old one while `app` is already unset): a
|
|
1657
|
+
* second Ctrl+C kills them too. Since the app leads its own group it no longer gets the
|
|
1658
|
+
* terminal's Ctrl+C itself, so without this the old app drained on for up to 15 s.
|
|
1659
|
+
*/
|
|
1660
|
+
const halting = new Set();
|
|
1661
|
+
const halt = async (target, limitMs = 15_000, group = false) => {
|
|
1662
|
+
if (!target)
|
|
1289
1663
|
return;
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1664
|
+
if (target.exitCode === null && target.signalCode === null) {
|
|
1665
|
+
const exited = new Promise((resolve) => target.once('exit', resolve));
|
|
1666
|
+
halting.add(target);
|
|
1667
|
+
target.kill('SIGTERM');
|
|
1668
|
+
const timer = setTimeout(() => target.kill('SIGKILL'), limitMs);
|
|
1669
|
+
await exited;
|
|
1670
|
+
clearTimeout(timer);
|
|
1671
|
+
halting.delete(target);
|
|
1672
|
+
}
|
|
1673
|
+
// The next app must not meet a port that a process of this one still holds.
|
|
1674
|
+
if (group && target.pid !== undefined)
|
|
1675
|
+
await leftovers(target.pid);
|
|
1295
1676
|
};
|
|
1296
|
-
// Vite builds in watch mode beside the app. The app starts after every first build (
|
|
1297
|
-
//
|
|
1677
|
+
// Vite builds in watch mode beside the app. The app starts after every first build (the
|
|
1678
|
+
// manifest and the SSR bundle it reads once at start must exist) and restarts after rebuilds.
|
|
1298
1679
|
const helpers = [];
|
|
1299
1680
|
const firstBuilds = [];
|
|
1300
1681
|
let built = () => { };
|
|
@@ -1346,23 +1727,52 @@ export async function dev(root, say) {
|
|
|
1346
1727
|
throw error;
|
|
1347
1728
|
}
|
|
1348
1729
|
let timer;
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
}
|
|
1730
|
+
// The first start is chained below, once the watched files are recorded.
|
|
1731
|
+
let restarting = Promise.resolve();
|
|
1732
|
+
/** What changed since the last restart or reload: dokebi.yaml alone may be reloaded. */
|
|
1733
|
+
let pending = { config: false, other: undefined };
|
|
1353
1734
|
const changed = (what) => {
|
|
1354
1735
|
if (exiting)
|
|
1355
1736
|
return;
|
|
1737
|
+
if (what === settingsName)
|
|
1738
|
+
pending.config = true;
|
|
1739
|
+
else
|
|
1740
|
+
pending.other ??= what;
|
|
1356
1741
|
clearTimeout(timer);
|
|
1357
1742
|
timer = setTimeout(() => {
|
|
1743
|
+
const batch = pending;
|
|
1744
|
+
pending = { config: false, other: undefined };
|
|
1358
1745
|
restarting = restarting
|
|
1359
1746
|
.then(async () => {
|
|
1360
1747
|
if (exiting)
|
|
1361
1748
|
return;
|
|
1749
|
+
if (batch.config && batch.other === undefined && app) {
|
|
1750
|
+
const answer = await askDevReload(app, await devReloadTimeout(root));
|
|
1751
|
+
debug('dev: reload answer', {
|
|
1752
|
+
outcome: answer?.outcome ?? 'no-answer',
|
|
1753
|
+
code: answer?.code,
|
|
1754
|
+
});
|
|
1755
|
+
if (answer?.outcome === 'applied') {
|
|
1756
|
+
say(`${settingsName} reloaded without a restart${answer.paths?.length ? `: ${answer.paths.join(', ')}` : ''}`);
|
|
1757
|
+
return;
|
|
1758
|
+
}
|
|
1759
|
+
if (answer?.outcome === 'unchanged')
|
|
1760
|
+
return;
|
|
1761
|
+
if (answer?.outcome === 'invalid') {
|
|
1762
|
+
say(`${settingsName} is invalid; the app keeps its current settings (its log says where); fix it and save again`);
|
|
1763
|
+
return;
|
|
1764
|
+
}
|
|
1765
|
+
// S-c: an app with no reload hook is still starting, or passes no configFile (R12).
|
|
1766
|
+
if (answer?.outcome === 'unsupported')
|
|
1767
|
+
say(`${settingsName}: the app did not take it (it is still starting, or src/main.ts passes no configFile to new Application or ClusterRunner: doctor says RELOAD_NOT_WIRED then)`);
|
|
1768
|
+
if (answer?.outcome === 'restart-required' && answer.restartRequired?.length)
|
|
1769
|
+
say(`${settingsName}: ${answer.restartRequired.join(', ')} ${answer.restartRequired.length === 1 ? 'needs' : 'need'} a restart`);
|
|
1770
|
+
}
|
|
1771
|
+
const what = batch.other ?? settingsName;
|
|
1362
1772
|
say(`${what} changed; restarting`);
|
|
1363
1773
|
const previous = app;
|
|
1364
1774
|
app = undefined;
|
|
1365
|
-
await halt(previous);
|
|
1775
|
+
await halt(previous, undefined, true);
|
|
1366
1776
|
if (!exiting)
|
|
1367
1777
|
await run();
|
|
1368
1778
|
})
|
|
@@ -1373,27 +1783,62 @@ export async function dev(root, say) {
|
|
|
1373
1783
|
}, 300);
|
|
1374
1784
|
};
|
|
1375
1785
|
built = (label) => changed(`${label} build`);
|
|
1786
|
+
// Events restart the app only for paths that really changed since they were recorded (see
|
|
1787
|
+
// dev-watch.ts: macOS also reports writes made before the watch started). An event without a
|
|
1788
|
+
// name cannot be checked and restarts.
|
|
1789
|
+
const source = join(root, 'src');
|
|
1790
|
+
// Styles and components reach the app through Vite (HMR, or the watch builds that restart it).
|
|
1791
|
+
const viewFile = (name) => (hot && VIEW_FILE.test(name)) ||
|
|
1792
|
+
(helpers.length > 0 && /\.(vue|css|scss|sass|less)$/.test(name));
|
|
1793
|
+
const watched = new WatchedState({ skip: (path) => viewFile(relative(source, path)) });
|
|
1376
1794
|
const watchers = [
|
|
1377
|
-
watch(
|
|
1378
|
-
|
|
1379
|
-
if (hot && name && VIEW_FILE.test(name))
|
|
1795
|
+
watch(source, { recursive: true }, (_event, name) => {
|
|
1796
|
+
if (name && viewFile(name))
|
|
1380
1797
|
return;
|
|
1381
|
-
if (
|
|
1798
|
+
if (name && !watched.changedIn(source, name))
|
|
1382
1799
|
return;
|
|
1383
1800
|
changed(`src/${name ?? ''}`);
|
|
1384
1801
|
}),
|
|
1385
1802
|
watch(root, (_event, name) => {
|
|
1386
|
-
if (name
|
|
1387
|
-
|
|
1803
|
+
if (!name)
|
|
1804
|
+
return;
|
|
1805
|
+
if ((name === CONFIG_FILE && !settings.fromEnvironment) || name === '.env') {
|
|
1806
|
+
if (watched.changed(join(root, name)))
|
|
1807
|
+
changed(name);
|
|
1808
|
+
}
|
|
1388
1809
|
// A running Vite reloads its own config; a host that failed to start tries again.
|
|
1389
|
-
else if (viteHost &&
|
|
1390
|
-
changed(name)
|
|
1810
|
+
else if (viteHost && /^vite\.config\.[cm]?[jt]s$/.test(name) && !viteHost.running) {
|
|
1811
|
+
if (watched.changed(join(root, name)))
|
|
1812
|
+
changed(name);
|
|
1813
|
+
}
|
|
1391
1814
|
}),
|
|
1392
1815
|
];
|
|
1816
|
+
// A settings file DOKEBI_CONFIG names: its own folder is watched for it (any folder, also one
|
|
1817
|
+
// outside the project: dev only reads it).
|
|
1818
|
+
if (settings.fromEnvironment)
|
|
1819
|
+
watchers.push(watch(dirname(settings.path), (_event, name) => {
|
|
1820
|
+
if (name === basename(settings.path) && watched.changed(settings.path))
|
|
1821
|
+
changed(settingsName);
|
|
1822
|
+
}));
|
|
1823
|
+
// After the watchers start and before the app starts: a write after this point is an event
|
|
1824
|
+
// with a new state; a write before it is already what the app reads.
|
|
1825
|
+
watched.recordTree(source);
|
|
1826
|
+
for (const path of [settings.path, join(root, '.env')])
|
|
1827
|
+
watched.record(path);
|
|
1828
|
+
for (const extension of ['ts', 'mts', 'cts', 'js', 'mjs', 'cjs'])
|
|
1829
|
+
watched.record(join(root, `vite.config.${extension}`));
|
|
1830
|
+
restarting = run().catch((error) => {
|
|
1831
|
+
// Keep the loop alive: the next change tries again.
|
|
1832
|
+
reportError('DEV_START_FAILED', 'Starting the app failed', error);
|
|
1833
|
+
});
|
|
1393
1834
|
return await new Promise((resolve) => {
|
|
1394
1835
|
const finish = async () => {
|
|
1395
1836
|
if (exiting) {
|
|
1396
|
-
app
|
|
1837
|
+
for (const target of new Set([...(app ? [app] : []), ...halting])) {
|
|
1838
|
+
target.kill('SIGKILL');
|
|
1839
|
+
if (target.pid !== undefined && PROCESS_GROUPS)
|
|
1840
|
+
killGroup(target.pid, 'SIGKILL');
|
|
1841
|
+
}
|
|
1397
1842
|
for (const helper of helpers)
|
|
1398
1843
|
helper.kill('SIGKILL');
|
|
1399
1844
|
await viteHost?.stop(0, true);
|
|
@@ -1406,7 +1851,10 @@ export async function dev(root, say) {
|
|
|
1406
1851
|
for (const watcher of watchers)
|
|
1407
1852
|
watcher.close();
|
|
1408
1853
|
await restarting;
|
|
1409
|
-
await Promise.all([
|
|
1854
|
+
await Promise.all([
|
|
1855
|
+
halt(app, undefined, true),
|
|
1856
|
+
...helpers.map((helper) => halt(helper, 5000)),
|
|
1857
|
+
]);
|
|
1410
1858
|
// After the app: its requests and server renders go to the host until it has drained.
|
|
1411
1859
|
await viteHost?.stop();
|
|
1412
1860
|
resolve(0);
|
|
@@ -1415,3 +1863,242 @@ export async function dev(root, say) {
|
|
|
1415
1863
|
process.on('SIGTERM', () => void finish());
|
|
1416
1864
|
});
|
|
1417
1865
|
}
|
|
1866
|
+
/** The variable that tells the app where to write its reload outcomes (core RELOAD_STATUS_ENV). */
|
|
1867
|
+
const RELOAD_STATUS_ENV = 'DOKEBI_RELOAD_STATUS';
|
|
1868
|
+
/** The app's last reload outcome (RELOAD_STATUS_ENV), or undefined. */
|
|
1869
|
+
async function reloadStatusOf(file) {
|
|
1870
|
+
try {
|
|
1871
|
+
return JSON.parse(await readFile(file, 'utf8'));
|
|
1872
|
+
}
|
|
1873
|
+
catch (error) {
|
|
1874
|
+
// Not written yet (no reload so far), or being replaced: no outcome to read now.
|
|
1875
|
+
if (isMissing(error) || error instanceof SyntaxError)
|
|
1876
|
+
return undefined;
|
|
1877
|
+
throw error;
|
|
1878
|
+
}
|
|
1879
|
+
}
|
|
1880
|
+
/** The log codes a reload ends with; the app writes one after every SIGHUP (D-0154). */
|
|
1881
|
+
const RELOAD_OUTCOMES = new Set([
|
|
1882
|
+
'CONFIG_RELOADED',
|
|
1883
|
+
'CONFIG_RELOAD_UNCHANGED',
|
|
1884
|
+
'CONFIG_RELOAD_INVALID',
|
|
1885
|
+
'CONFIG_RELOAD_RESTART_REQUIRED',
|
|
1886
|
+
'CONFIG_RELOAD_FAILED',
|
|
1887
|
+
'CONFIG_RELOAD_TIMEOUT',
|
|
1888
|
+
'CONFIG_RELOAD_PARTIAL',
|
|
1889
|
+
'CONFIG_RELOAD_UNAVAILABLE',
|
|
1890
|
+
]);
|
|
1891
|
+
/** Versions of @dokebi/core whose Application reads dokebi.yaml again on SIGHUP. */
|
|
1892
|
+
const RELOAD_CORE = [0, 4, 2];
|
|
1893
|
+
function newer(version, than) {
|
|
1894
|
+
for (let index = 0; index < 3; index++) {
|
|
1895
|
+
const a = version[index] ?? 0;
|
|
1896
|
+
const b = than[index] ?? 0;
|
|
1897
|
+
if (a !== b)
|
|
1898
|
+
return a > b;
|
|
1899
|
+
}
|
|
1900
|
+
return true;
|
|
1901
|
+
}
|
|
1902
|
+
/**
|
|
1903
|
+
* Whether the background app reads dokebi.yaml again on SIGHUP: core 0.4.2 or newer and a
|
|
1904
|
+
* src/main.ts that gives its Application (or @dokebi/cluster runner) `configFile`, unchanged since
|
|
1905
|
+
* the app started. Without that a SIGHUP would end the app, so the command refuses.
|
|
1906
|
+
*/
|
|
1907
|
+
async function reloadSupport(root, record) {
|
|
1908
|
+
const core = installedVersion(root, '@dokebi/core');
|
|
1909
|
+
if (!core || !newer(core, RELOAD_CORE))
|
|
1910
|
+
return `@dokebi/core ${core ? core.join('.') : '(not installed)'} cannot read dokebi.yaml again; it needs 0.4.2 or newer`;
|
|
1911
|
+
const main = paths(root).entry;
|
|
1912
|
+
const [text, info] = await Promise.all([readFile(main, 'utf8'), stat(main)]);
|
|
1913
|
+
if (!/\bconfigFile\s*:/.test(text))
|
|
1914
|
+
return `${ENTRY} gives the app no configFile, so a SIGHUP would end it instead of reloading`;
|
|
1915
|
+
if (info.mtimeMs > Date.parse(record.startedAt) + 1000)
|
|
1916
|
+
return `${ENTRY} changed after the app started; the running app may not read dokebi.yaml again`;
|
|
1917
|
+
return undefined;
|
|
1918
|
+
}
|
|
1919
|
+
/** The first reload outcome the app logged after `offset` of app.log (a SIGHUP's, `trigger: signal`). */
|
|
1920
|
+
async function reloadOutcome(log, offset) {
|
|
1921
|
+
let text;
|
|
1922
|
+
try {
|
|
1923
|
+
const handle = await open(log, 'r');
|
|
1924
|
+
try {
|
|
1925
|
+
const { size } = await handle.stat();
|
|
1926
|
+
// A rotation since the signal starts a new file: read it from the beginning.
|
|
1927
|
+
const from = size < offset ? 0 : offset;
|
|
1928
|
+
const length = Math.min(size - from, 4 * MB);
|
|
1929
|
+
const buffer = Buffer.alloc(Math.max(0, length));
|
|
1930
|
+
if (length > 0)
|
|
1931
|
+
await handle.read(buffer, 0, length, from);
|
|
1932
|
+
text = buffer.toString('utf8');
|
|
1933
|
+
}
|
|
1934
|
+
finally {
|
|
1935
|
+
await handle.close();
|
|
1936
|
+
}
|
|
1937
|
+
}
|
|
1938
|
+
catch (error) {
|
|
1939
|
+
if (isMissing(error))
|
|
1940
|
+
return undefined;
|
|
1941
|
+
throw error;
|
|
1942
|
+
}
|
|
1943
|
+
for (const line of text.split('\n')) {
|
|
1944
|
+
if (!line.includes('CONFIG_RELOAD'))
|
|
1945
|
+
continue;
|
|
1946
|
+
let record;
|
|
1947
|
+
try {
|
|
1948
|
+
record = JSON.parse(line);
|
|
1949
|
+
}
|
|
1950
|
+
catch {
|
|
1951
|
+
// dokebi-check: allow empty-catch a log line that is not JSON (a readable line, a cut line) is not the outcome
|
|
1952
|
+
continue;
|
|
1953
|
+
}
|
|
1954
|
+
if (typeof record.code === 'string' &&
|
|
1955
|
+
RELOAD_OUTCOMES.has(record.code) &&
|
|
1956
|
+
record.trigger === 'signal')
|
|
1957
|
+
return record;
|
|
1958
|
+
}
|
|
1959
|
+
return undefined;
|
|
1960
|
+
}
|
|
1961
|
+
/**
|
|
1962
|
+
* `dokebi reload` (D-0154): asks the background app to read dokebi.yaml again (SIGHUP to the pid
|
|
1963
|
+
* `start` recorded, proven like `stop`; through its log writer when this shell may not signal
|
|
1964
|
+
* it), then reads its outcome from .dokebi/run/app.log. Options marked reloadable apply without
|
|
1965
|
+
* a restart; an option that needs one (`RELOAD_RESTART_REQUIRED`), an invalid file
|
|
1966
|
+
* (`RELOAD_INVALID`) or a plugin that could not prepare (`RELOAD_FAILED`) change nothing.
|
|
1967
|
+
*/
|
|
1968
|
+
export async function reload(root, timeoutMs, read = readProcess, kill = signalProcess) {
|
|
1969
|
+
daemonOnly();
|
|
1970
|
+
const record = await readRecord(root);
|
|
1971
|
+
if (!record)
|
|
1972
|
+
return { running: false };
|
|
1973
|
+
let proof = 'process';
|
|
1974
|
+
let found;
|
|
1975
|
+
try {
|
|
1976
|
+
found = Boolean(await live(record, read));
|
|
1977
|
+
}
|
|
1978
|
+
catch (error) {
|
|
1979
|
+
if (!processInfoFailure(error))
|
|
1980
|
+
throw error;
|
|
1981
|
+
const how = await withoutProcessInfo(root, record, error, kill);
|
|
1982
|
+
found = how !== 'gone';
|
|
1983
|
+
if (how !== 'gone')
|
|
1984
|
+
proof = how;
|
|
1985
|
+
}
|
|
1986
|
+
if (!found)
|
|
1987
|
+
return { running: false, pid: record.pid };
|
|
1988
|
+
const unsupported = await reloadSupport(root, record);
|
|
1989
|
+
if (unsupported)
|
|
1990
|
+
throw new CliError('RELOAD_UNSUPPORTED', unsupported, {
|
|
1991
|
+
details: {
|
|
1992
|
+
pid: record.pid,
|
|
1993
|
+
fix: `give the Application configFile in ${ENTRY} (new Application({ config: document.options, configFile: configPath, … }); the generated ${ENTRY} does since @dokebi/cli 0.4.26), then restart: npx dokebi stop && npx dokebi start`,
|
|
1994
|
+
},
|
|
1995
|
+
});
|
|
1996
|
+
const wait = timeoutMs ?? (await devReloadTimeout(root));
|
|
1997
|
+
const { log, reloadStatus } = paths(root);
|
|
1998
|
+
// The outcome file of the app (whatever its log level) is preferred; the log is the fallback.
|
|
1999
|
+
const before = await reloadStatusOf(reloadStatus);
|
|
2000
|
+
const offset = await stat(log).then((info) => info.size, (error) => {
|
|
2001
|
+
if (isMissing(error))
|
|
2002
|
+
return 0;
|
|
2003
|
+
throw error;
|
|
2004
|
+
});
|
|
2005
|
+
const began = performance.now();
|
|
2006
|
+
let via = 'signal';
|
|
2007
|
+
const throughWriter = async (refused) => {
|
|
2008
|
+
const asked = await askWriter(record, { op: 'reload' });
|
|
2009
|
+
if (!asked.reached)
|
|
2010
|
+
throw new CliError('RELOAD_FAILED', `Unable to signal pid ${record.pid}: this shell may not signal it and its log writer could not be asked (${asked.reason})`, {
|
|
2011
|
+
cause: refused,
|
|
2012
|
+
details: {
|
|
2013
|
+
pid: record.pid,
|
|
2014
|
+
fix: 'run npx dokebi reload from a shell that may signal the app (outside the sandbox), or save dokebi.yaml (the app watches it unless options.reload.watch is false)',
|
|
2015
|
+
},
|
|
2016
|
+
});
|
|
2017
|
+
await asked.closed(0);
|
|
2018
|
+
via = 'log-writer';
|
|
2019
|
+
};
|
|
2020
|
+
debug('Sending SIGHUP to reload the configuration', { pid: record.pid, proof });
|
|
2021
|
+
if (proof === 'log-writer')
|
|
2022
|
+
await throughWriter();
|
|
2023
|
+
else
|
|
2024
|
+
try {
|
|
2025
|
+
kill(record.pid, 'SIGHUP');
|
|
2026
|
+
}
|
|
2027
|
+
catch (error) {
|
|
2028
|
+
if (error.code !== 'EPERM')
|
|
2029
|
+
throw error;
|
|
2030
|
+
debug('SIGHUP refused; asking the log writer', { pid: record.pid });
|
|
2031
|
+
await throughWriter(error);
|
|
2032
|
+
}
|
|
2033
|
+
const until = performance.now() + wait;
|
|
2034
|
+
let outcome;
|
|
2035
|
+
while (performance.now() < until) {
|
|
2036
|
+
const status = await reloadStatusOf(reloadStatus);
|
|
2037
|
+
if (status &&
|
|
2038
|
+
(status.pid !== before?.pid || status.sequence !== before?.sequence) &&
|
|
2039
|
+
typeof status.code === 'string') {
|
|
2040
|
+
outcome = status;
|
|
2041
|
+
break;
|
|
2042
|
+
}
|
|
2043
|
+
outcome = await reloadOutcome(log, offset);
|
|
2044
|
+
if (outcome)
|
|
2045
|
+
break;
|
|
2046
|
+
await delay(100);
|
|
2047
|
+
}
|
|
2048
|
+
const ms = Math.round(performance.now() - began);
|
|
2049
|
+
const list = (value) => Array.isArray(value) ? value.slice(0, 64).map((item) => String(item)) : undefined;
|
|
2050
|
+
if (!outcome)
|
|
2051
|
+
return {
|
|
2052
|
+
running: true,
|
|
2053
|
+
pid: record.pid,
|
|
2054
|
+
outcome: 'unknown',
|
|
2055
|
+
ms,
|
|
2056
|
+
proof,
|
|
2057
|
+
via,
|
|
2058
|
+
warnings: [
|
|
2059
|
+
`no reload outcome in ${log} within ${wait} ms: the app may log above info (options.logging.level), or be busy; npx dokebi logs shows what it said`,
|
|
2060
|
+
],
|
|
2061
|
+
};
|
|
2062
|
+
const code = String(outcome.code);
|
|
2063
|
+
const paths_ = list(outcome.paths);
|
|
2064
|
+
if (code === 'CONFIG_RELOADED' || code === 'CONFIG_RELOAD_UNCHANGED')
|
|
2065
|
+
return {
|
|
2066
|
+
running: true,
|
|
2067
|
+
pid: record.pid,
|
|
2068
|
+
outcome: code === 'CONFIG_RELOADED' ? 'applied' : 'unchanged',
|
|
2069
|
+
code,
|
|
2070
|
+
...(typeof outcome.revision === 'number' ? { revision: outcome.revision } : {}),
|
|
2071
|
+
...(paths_ ? { paths: paths_ } : {}),
|
|
2072
|
+
ms,
|
|
2073
|
+
proof,
|
|
2074
|
+
via,
|
|
2075
|
+
};
|
|
2076
|
+
const details = {
|
|
2077
|
+
pid: record.pid,
|
|
2078
|
+
code,
|
|
2079
|
+
...(paths_ ? { paths: paths_ } : {}),
|
|
2080
|
+
...(list(outcome.restartRequired) ? { restartRequired: list(outcome.restartRequired) } : {}),
|
|
2081
|
+
...(list(outcome.issues) ? { issues: list(outcome.issues) } : {}),
|
|
2082
|
+
...(typeof outcome.line === 'number' ? { line: outcome.line, column: outcome.column } : {}),
|
|
2083
|
+
...(typeof outcome.plugin === 'string' ? { plugin: outcome.plugin } : {}),
|
|
2084
|
+
...(typeof outcome.reason === 'string' ? { reason: outcome.reason } : {}),
|
|
2085
|
+
...(typeof outcome.fix === 'string' ? { fix: outcome.fix } : {}),
|
|
2086
|
+
};
|
|
2087
|
+
if (code === 'CONFIG_RELOAD_RESTART_REQUIRED')
|
|
2088
|
+
throw new CliError('RELOAD_RESTART_REQUIRED', `The change needs a restart (${(list(outcome.restartRequired) ?? []).join(', ')}); nothing was applied`, {
|
|
2089
|
+
details: {
|
|
2090
|
+
...details,
|
|
2091
|
+
fix: 'restart the app to apply these options: npx dokebi stop && npx dokebi start',
|
|
2092
|
+
},
|
|
2093
|
+
});
|
|
2094
|
+
if (code === 'CONFIG_RELOAD_INVALID')
|
|
2095
|
+
throw new CliError('RELOAD_INVALID', 'dokebi.yaml is invalid; the app keeps its current settings', {
|
|
2096
|
+
details: {
|
|
2097
|
+
...details,
|
|
2098
|
+
fix: 'correct the named paths or line of dokebi.yaml (npx dokebi doctor checks it), then run npx dokebi reload again',
|
|
2099
|
+
},
|
|
2100
|
+
});
|
|
2101
|
+
throw new CliError('RELOAD_FAILED', `The app did not reload (${code}); nothing was applied`, {
|
|
2102
|
+
details,
|
|
2103
|
+
});
|
|
2104
|
+
}
|