@dokebi/cli 0.2.2 → 0.2.4
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 +25 -5
- package/README.md +3 -1
- package/dist/bin.js +110 -1
- package/dist/configuration-scaffold.js +3 -0
- package/dist/configuration.d.ts +2 -2
- package/dist/configuration.js +11 -2
- package/dist/dev-guard.d.ts +1 -0
- package/dist/dev-guard.js +9 -0
- package/dist/diagnostics.d.ts +21 -0
- package/dist/diagnostics.js +87 -0
- package/dist/install-module.js +13 -2
- package/dist/log-writer.d.ts +1 -0
- package/dist/log-writer.js +133 -0
- package/dist/logging-config.d.ts +14 -0
- package/dist/logging-config.js +48 -0
- package/dist/npm-client.js +9 -0
- package/dist/output.d.ts +1 -1
- package/dist/output.js +1 -1
- package/dist/project-inspector.js +9 -0
- package/dist/project.js +9 -5
- package/dist/rotating-log.d.ts +27 -0
- package/dist/rotating-log.js +166 -0
- package/dist/server.d.ts +99 -0
- package/dist/server.js +857 -0
- package/dist/transaction.js +10 -1
- package/dist/vite-watch.d.ts +1 -0
- package/dist/vite-watch.js +23 -0
- package/dist/workspace.js +6 -2
- package/package.json +3 -3
package/dist/server.js
ADDED
|
@@ -0,0 +1,857 @@
|
|
|
1
|
+
// Server commands (D-0044): dev, start, stop, status and logs for the project's one process
|
|
2
|
+
// (`src/main.ts`). The background process is tracked by `.dokebi/run/app.pid`. Its stdout and
|
|
3
|
+
// stderr are pipes into a detached log writer (`log-writer.ts`, D-0053) that writes
|
|
4
|
+
// `.dokebi/run/app.log` and rotates it by size (`app.log.1` … `app.log.<files>`). A pid is only
|
|
5
|
+
// acted on when `ps` (/proc on Linux) shows it runs this project's entry and started when the pid
|
|
6
|
+
// file says, so a reused pid is never signalled.
|
|
7
|
+
import { execFile, spawn } from 'node:child_process';
|
|
8
|
+
import { existsSync, readFileSync, watch } from 'node:fs';
|
|
9
|
+
import { mkdir, open, readdir, readFile, rename, rm, stat, writeFile, } from 'node:fs/promises';
|
|
10
|
+
import { basename, dirname, join } from 'node:path';
|
|
11
|
+
import { setTimeout as delay } from 'node:timers/promises';
|
|
12
|
+
import { fileURLToPath } from 'node:url';
|
|
13
|
+
import { promisify } from 'node:util';
|
|
14
|
+
import { debug, debugEnabled, isMissing, reportError } from './diagnostics.js';
|
|
15
|
+
import { CliError } from './errors.js';
|
|
16
|
+
import { ProjectInspector } from './project-inspector.js';
|
|
17
|
+
import { logFiles } from './rotating-log.js';
|
|
18
|
+
const exec = promisify(execFile);
|
|
19
|
+
const ENTRY = 'src/main.ts';
|
|
20
|
+
const RUN = '.dokebi/run';
|
|
21
|
+
/** Written by the generated `src/main.ts` once `app.start()` resolved. */
|
|
22
|
+
const STARTED = 'Dokebi application started';
|
|
23
|
+
/** Rotation defaults and limits (D-0053): `--log-max-mb`, `--log-files`. */
|
|
24
|
+
export const LOG_LIMITS = {
|
|
25
|
+
maxMb: { default: 50, min: 1, max: 10_240 },
|
|
26
|
+
files: { default: 5, min: 1, max: 100 },
|
|
27
|
+
};
|
|
28
|
+
const MB = 1024 * 1024;
|
|
29
|
+
const paths = (root) => ({
|
|
30
|
+
run: join(root, RUN),
|
|
31
|
+
pid: join(root, RUN, 'app.pid'),
|
|
32
|
+
log: join(root, RUN, 'app.log'),
|
|
33
|
+
writerLog: join(root, RUN, 'log-writer.log'),
|
|
34
|
+
lock: join(root, RUN, 'start.lock'),
|
|
35
|
+
entry: join(root, ENTRY),
|
|
36
|
+
});
|
|
37
|
+
function daemonOnly() {
|
|
38
|
+
if (process.platform === 'win32')
|
|
39
|
+
throw new CliError('UNSUPPORTED_PLATFORM', 'Background start and stop need POSIX signals; on Windows use dokebi dev or dokebi start --foreground');
|
|
40
|
+
}
|
|
41
|
+
async function readRecord(root) {
|
|
42
|
+
let text;
|
|
43
|
+
try {
|
|
44
|
+
text = await readFile(paths(root).pid, 'utf8');
|
|
45
|
+
}
|
|
46
|
+
catch (error) {
|
|
47
|
+
// No pid file means not started; anything else is reported and treated the same way.
|
|
48
|
+
if (!isMissing(error))
|
|
49
|
+
reportError('PID_FILE_READ_FAILED', 'Unable to read the pid file', error);
|
|
50
|
+
return undefined;
|
|
51
|
+
}
|
|
52
|
+
try {
|
|
53
|
+
const value = JSON.parse(text);
|
|
54
|
+
if (Number.isSafeInteger(value.pid) && value.pid > 0 && typeof value.entry === 'string')
|
|
55
|
+
return value;
|
|
56
|
+
debug('Pid file ignored: unexpected content', { file: `${RUN}/app.pid` });
|
|
57
|
+
}
|
|
58
|
+
catch (error) {
|
|
59
|
+
reportError('PID_FILE_INVALID', 'Pid file is not valid JSON; treating the app as stopped', error);
|
|
60
|
+
}
|
|
61
|
+
return undefined;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Linux reads /proc: slim container images (node:*-slim) have no `ps`. Start time is the boot
|
|
65
|
+
* time plus the process start in clock ticks (USER_HZ, 100 on Linux).
|
|
66
|
+
*/
|
|
67
|
+
async function procInfo(pid) {
|
|
68
|
+
try {
|
|
69
|
+
const [cmdline, stat, status, uptime] = await Promise.all([
|
|
70
|
+
readFile(`/proc/${pid}/cmdline`, 'utf8'),
|
|
71
|
+
readFile(`/proc/${pid}/stat`, 'utf8'),
|
|
72
|
+
readFile(`/proc/${pid}/status`, 'utf8'),
|
|
73
|
+
readFile('/proc/stat', 'utf8'),
|
|
74
|
+
]);
|
|
75
|
+
// Fields after the parenthesised command name: state is field 3, starttime field 22.
|
|
76
|
+
const fields = stat.slice(stat.lastIndexOf(')') + 2).split(' ');
|
|
77
|
+
const ticks = Number(fields[19]);
|
|
78
|
+
const boot = Number(/^btime (\d+)$/m.exec(uptime)?.[1]);
|
|
79
|
+
if (!Number.isFinite(ticks) || !Number.isFinite(boot))
|
|
80
|
+
return undefined;
|
|
81
|
+
return {
|
|
82
|
+
startedAt: (boot + ticks / 100) * 1000,
|
|
83
|
+
rssKb: Number(/^VmRSS:\s+(\d+)/m.exec(status)?.[1] ?? 0),
|
|
84
|
+
command: cmdline.split('\0').join(' ').trim(),
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
catch (error) {
|
|
88
|
+
// ENOENT: no such process. Anything else (permissions, a changed /proc) is reported.
|
|
89
|
+
if (!isMissing(error))
|
|
90
|
+
reportError('PROCESS_INFO_FAILED', 'Unable to read process information from /proc', error);
|
|
91
|
+
return undefined;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
async function processInfo(pid) {
|
|
95
|
+
if (process.platform === 'linux')
|
|
96
|
+
return procInfo(pid);
|
|
97
|
+
try {
|
|
98
|
+
const { stdout } = await exec('ps', ['-ww', '-o', 'lstart=,rss=,command=', '-p', String(pid)], {
|
|
99
|
+
env: { ...process.env, LC_ALL: 'C' },
|
|
100
|
+
});
|
|
101
|
+
// lstart is a fixed 24-character date: "Thu Oct 15 00:21:26 2026".
|
|
102
|
+
const line = stdout.trim();
|
|
103
|
+
const startedAt = Date.parse(line.slice(0, 24));
|
|
104
|
+
const [rss = '', ...command] = line.slice(24).trim().split(/\s+/);
|
|
105
|
+
if (!Number.isFinite(startedAt))
|
|
106
|
+
return undefined;
|
|
107
|
+
return { startedAt, rssKb: Number(rss), command: command.join(' ') };
|
|
108
|
+
}
|
|
109
|
+
catch (error) {
|
|
110
|
+
// ps exits 1 when the pid does not exist; anything else (no ps, killed) is reported.
|
|
111
|
+
if (error.code !== 1)
|
|
112
|
+
reportError('PROCESS_INFO_FAILED', 'Unable to read process information with ps', error);
|
|
113
|
+
return undefined;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
/** The recorded process, when it is still alive and still this project's process. */
|
|
117
|
+
async function live(record) {
|
|
118
|
+
const info = await processInfo(record.pid);
|
|
119
|
+
if (!info?.command.includes(record.entry)) {
|
|
120
|
+
if (info)
|
|
121
|
+
debug('Recorded pid runs another command; treating it as stale', { pid: record.pid });
|
|
122
|
+
return undefined;
|
|
123
|
+
}
|
|
124
|
+
// lstart has one-second resolution; the pid file is written right after spawn.
|
|
125
|
+
if (Math.abs(info.startedAt - Date.parse(record.startedAt)) > 5000) {
|
|
126
|
+
debug('Recorded pid was reused by a later process; treating it as stale', { pid: record.pid });
|
|
127
|
+
return undefined;
|
|
128
|
+
}
|
|
129
|
+
return info;
|
|
130
|
+
}
|
|
131
|
+
/** Whether the record's log writer still runs (its command names the writer and the entry). */
|
|
132
|
+
async function writerAlive(record) {
|
|
133
|
+
if (record.logWriter === undefined)
|
|
134
|
+
return false;
|
|
135
|
+
const info = await processInfo(record.logWriter);
|
|
136
|
+
return Boolean(info?.command.includes('log-writer') && info.command.includes(record.entry));
|
|
137
|
+
}
|
|
138
|
+
async function logInfo(root) {
|
|
139
|
+
const { log, writerLog } = paths(root);
|
|
140
|
+
const sizes = async (file) => stat(file).then((stats) => stats.size, (error) => {
|
|
141
|
+
// Rotated away between listing and stat: it is simply no longer there.
|
|
142
|
+
if (!isMissing(error))
|
|
143
|
+
reportError('LOG_READ_FAILED', 'Unable to read a log file size', error);
|
|
144
|
+
return undefined;
|
|
145
|
+
});
|
|
146
|
+
const files = [];
|
|
147
|
+
for (const file of logFiles(log)) {
|
|
148
|
+
const bytes = await sizes(file);
|
|
149
|
+
if (bytes !== undefined)
|
|
150
|
+
files.push({ file, bytes });
|
|
151
|
+
}
|
|
152
|
+
const writerBytes = await sizes(writerLog);
|
|
153
|
+
return { logFiles: files, ...(writerBytes ? { logWriterLog: writerLog } : {}) };
|
|
154
|
+
}
|
|
155
|
+
export async function status(root) {
|
|
156
|
+
const { pid, log } = paths(root);
|
|
157
|
+
const record = await readRecord(root);
|
|
158
|
+
const logs = await logInfo(root);
|
|
159
|
+
if (!record)
|
|
160
|
+
return { running: false, log, ...logs };
|
|
161
|
+
const info = await live(record);
|
|
162
|
+
if (!info) {
|
|
163
|
+
await rm(pid, { force: true });
|
|
164
|
+
return {
|
|
165
|
+
running: false,
|
|
166
|
+
stale: true,
|
|
167
|
+
pid: record.pid,
|
|
168
|
+
startedAt: record.startedAt,
|
|
169
|
+
log,
|
|
170
|
+
...logs,
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
return {
|
|
174
|
+
running: true,
|
|
175
|
+
pid: record.pid,
|
|
176
|
+
startedAt: record.startedAt,
|
|
177
|
+
uptimeSec: Math.round((Date.now() - Date.parse(record.startedAt)) / 1000),
|
|
178
|
+
rssMb: Math.round(info.rssKb / 1024),
|
|
179
|
+
log,
|
|
180
|
+
...logs,
|
|
181
|
+
...(record.logWriter !== undefined
|
|
182
|
+
? { logWriter: { pid: record.logWriter, running: await writerAlive(record) } }
|
|
183
|
+
: {}),
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
async function checkProject(root) {
|
|
187
|
+
const { entry } = paths(root);
|
|
188
|
+
if (!existsSync(entry))
|
|
189
|
+
throw new CliError('ENTRY_MISSING', `${ENTRY} does not exist`);
|
|
190
|
+
const inspection = await new ProjectInspector().inspect(root);
|
|
191
|
+
if (!inspection.healthy)
|
|
192
|
+
throw new CliError('START_CHECK_FAILED', 'The project has errors; run dokebi doctor', {
|
|
193
|
+
details: {
|
|
194
|
+
diagnostics: inspection.diagnostics.filter((item) => item.severity === 'error'),
|
|
195
|
+
},
|
|
196
|
+
});
|
|
197
|
+
return entry;
|
|
198
|
+
}
|
|
199
|
+
const environment = (mode) => ({
|
|
200
|
+
...process.env,
|
|
201
|
+
NODE_ENV: process.env.NODE_ENV ?? mode,
|
|
202
|
+
});
|
|
203
|
+
/**
|
|
204
|
+
* The last `lines` lines of one file (reading at most its last `maxBytes`). An open handle is
|
|
205
|
+
* read up to `end` (the size it had when opened) and left open.
|
|
206
|
+
*/
|
|
207
|
+
async function lastLines(source, lines, maxBytes = MB) {
|
|
208
|
+
if (lines <= 0)
|
|
209
|
+
return [];
|
|
210
|
+
let handle;
|
|
211
|
+
try {
|
|
212
|
+
const opened = typeof source === 'string' ? await open(source, 'r') : source.handle;
|
|
213
|
+
if (typeof source === 'string')
|
|
214
|
+
handle = opened;
|
|
215
|
+
const size = typeof source === 'string' ? (await opened.stat()).size : source.end;
|
|
216
|
+
const length = Math.min(size, maxBytes);
|
|
217
|
+
const buffer = Buffer.alloc(length);
|
|
218
|
+
await opened.read(buffer, 0, length, size - length);
|
|
219
|
+
const text = buffer.toString('utf8').split('\n');
|
|
220
|
+
if (text.at(-1) === '')
|
|
221
|
+
text.pop();
|
|
222
|
+
return text.slice(-lines);
|
|
223
|
+
}
|
|
224
|
+
catch (error) {
|
|
225
|
+
// Rotated away while reading: the next older file has those lines.
|
|
226
|
+
if (!isMissing(error))
|
|
227
|
+
reportError('LOG_READ_FAILED', 'Unable to read the app log', error);
|
|
228
|
+
return [];
|
|
229
|
+
}
|
|
230
|
+
finally {
|
|
231
|
+
await handle?.close();
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
/**
|
|
235
|
+
* The last `lines` lines across app.log and its rotated files (D-0053). With `current` (app.log
|
|
236
|
+
* opened by the follower), app.log is read through it up to where following starts.
|
|
237
|
+
*/
|
|
238
|
+
async function tail(file, lines, current) {
|
|
239
|
+
let result = [];
|
|
240
|
+
const files = logFiles(file);
|
|
241
|
+
if (current && files[0] !== file)
|
|
242
|
+
files.unshift(file);
|
|
243
|
+
for (const each of files) {
|
|
244
|
+
const source = current && each === file ? current : each;
|
|
245
|
+
result = [...(await lastLines(source, lines - result.length)), ...result];
|
|
246
|
+
if (result.length >= lines)
|
|
247
|
+
break;
|
|
248
|
+
}
|
|
249
|
+
return result.length ? `${result.join('\n')}\n` : '';
|
|
250
|
+
}
|
|
251
|
+
/** Rejects rotation settings outside LOG_LIMITS; bytes below 1 MB are allowed for tests. */
|
|
252
|
+
function logSettings(options) {
|
|
253
|
+
const maxBytes = options.logMaxBytes ?? LOG_LIMITS.maxMb.default * MB;
|
|
254
|
+
const files = options.logFiles ?? LOG_LIMITS.files.default;
|
|
255
|
+
if (!Number.isSafeInteger(maxBytes) || maxBytes < 1024 || maxBytes > LOG_LIMITS.maxMb.max * MB)
|
|
256
|
+
throw new CliError('USAGE', `--log-max-mb must be an integer from ${LOG_LIMITS.maxMb.min} to ${LOG_LIMITS.maxMb.max}`);
|
|
257
|
+
if (!Number.isSafeInteger(files) || files < LOG_LIMITS.files.min || files > LOG_LIMITS.files.max)
|
|
258
|
+
throw new CliError('USAGE', `--log-files must be an integer from ${LOG_LIMITS.files.min} to ${LOG_LIMITS.files.max}`);
|
|
259
|
+
return { maxBytes, files };
|
|
260
|
+
}
|
|
261
|
+
export async function start(root, options = {},
|
|
262
|
+
/** Tests replace the project inspection; the CLI always inspects. */
|
|
263
|
+
check = checkProject) {
|
|
264
|
+
daemonOnly();
|
|
265
|
+
const { maxBytes, files: keep } = logSettings(options);
|
|
266
|
+
const waitMs = options.waitMs ?? 10_000;
|
|
267
|
+
const files = paths(root);
|
|
268
|
+
const began = performance.now();
|
|
269
|
+
const current = await status(root);
|
|
270
|
+
if (current.running)
|
|
271
|
+
throw new CliError('ALREADY_RUNNING', `Already running (pid ${current.pid})`, {
|
|
272
|
+
details: current,
|
|
273
|
+
});
|
|
274
|
+
const entry = await check(root);
|
|
275
|
+
await mkdir(files.run, { recursive: true });
|
|
276
|
+
await writeFile(join(files.run, '.gitignore'), '*\n');
|
|
277
|
+
// One start at a time; a lock left by a start that died more than a minute ago is stale.
|
|
278
|
+
try {
|
|
279
|
+
await writeFile(files.lock, String(process.pid), { flag: 'wx' });
|
|
280
|
+
}
|
|
281
|
+
catch (error) {
|
|
282
|
+
if (error.code !== 'EEXIST')
|
|
283
|
+
throw error;
|
|
284
|
+
const age = Date.now() -
|
|
285
|
+
(await stat(files.lock).catch((cause) => {
|
|
286
|
+
// The other start removed its lock meanwhile: the lock counts as stale.
|
|
287
|
+
if (!isMissing(cause))
|
|
288
|
+
throw cause;
|
|
289
|
+
return { mtimeMs: 0 };
|
|
290
|
+
})).mtimeMs;
|
|
291
|
+
if (age < 60_000)
|
|
292
|
+
throw new CliError('START_IN_PROGRESS', 'Another dokebi start is running for this project');
|
|
293
|
+
debug('Replacing a stale start lock', { ageMs: age });
|
|
294
|
+
await writeFile(files.lock, String(process.pid));
|
|
295
|
+
}
|
|
296
|
+
const warnings = [];
|
|
297
|
+
let writer;
|
|
298
|
+
try {
|
|
299
|
+
// The writer's own diagnostics (it has no app logger) go to log-writer.log.
|
|
300
|
+
const writerLog = await open(files.writerLog, 'a');
|
|
301
|
+
try {
|
|
302
|
+
writer = spawn(process.execPath, [
|
|
303
|
+
fileURLToPath(new URL('./log-writer.js', import.meta.url)),
|
|
304
|
+
files.log,
|
|
305
|
+
String(maxBytes),
|
|
306
|
+
String(keep),
|
|
307
|
+
entry,
|
|
308
|
+
], {
|
|
309
|
+
cwd: root,
|
|
310
|
+
detached: true,
|
|
311
|
+
stdio: ['ignore', 'ignore', writerLog.fd, 'ipc'],
|
|
312
|
+
env: environment('production'),
|
|
313
|
+
});
|
|
314
|
+
}
|
|
315
|
+
finally {
|
|
316
|
+
await writerLog.close();
|
|
317
|
+
}
|
|
318
|
+
const writerPid = writer.pid;
|
|
319
|
+
if (writerPid === undefined)
|
|
320
|
+
throw new CliError('START_FAILED', 'The log writer process could not be spawned');
|
|
321
|
+
const state = {};
|
|
322
|
+
writer.on('message', (message) => {
|
|
323
|
+
if (typeof message?.spawned === 'number')
|
|
324
|
+
state.pid = message.spawned;
|
|
325
|
+
if (message?.ready)
|
|
326
|
+
state.ready = true;
|
|
327
|
+
if (message && 'exited' in message)
|
|
328
|
+
state.exited = message.signal ? `signal ${message.signal}` : `code ${message.exited}`;
|
|
329
|
+
if (typeof message?.failed === 'string')
|
|
330
|
+
state.failed = message.failed;
|
|
331
|
+
});
|
|
332
|
+
writer.once('exit', (code, signal) => {
|
|
333
|
+
// The writer ends with 0 only after the app ended (its message may still be on the way).
|
|
334
|
+
if (code === 0)
|
|
335
|
+
state.exited ??= 'an unknown code (see the log)';
|
|
336
|
+
else
|
|
337
|
+
state.failed ??= `the log writer exited (${signal ?? code})`;
|
|
338
|
+
});
|
|
339
|
+
debug('Log writer spawned; waiting for the app', { writer: writerPid, waitMs, maxBytes, keep });
|
|
340
|
+
const failure = async (message) => {
|
|
341
|
+
await rm(files.pid, { force: true });
|
|
342
|
+
return new CliError('START_FAILED', message, {
|
|
343
|
+
details: {
|
|
344
|
+
log: files.log,
|
|
345
|
+
tail: await tail(files.log, 30),
|
|
346
|
+
logWriterLog: (await lastLines(files.writerLog, 30)).join('\n'),
|
|
347
|
+
},
|
|
348
|
+
});
|
|
349
|
+
};
|
|
350
|
+
const deadline = performance.now() + waitMs;
|
|
351
|
+
let recorded;
|
|
352
|
+
while (performance.now() < deadline) {
|
|
353
|
+
await delay(50);
|
|
354
|
+
if (state.pid !== undefined && recorded === undefined) {
|
|
355
|
+
recorded = state.pid;
|
|
356
|
+
const record = {
|
|
357
|
+
pid: state.pid,
|
|
358
|
+
startedAt: new Date().toISOString(),
|
|
359
|
+
entry,
|
|
360
|
+
node: process.version,
|
|
361
|
+
logWriter: writerPid,
|
|
362
|
+
logMaxBytes: maxBytes,
|
|
363
|
+
logFiles: keep,
|
|
364
|
+
};
|
|
365
|
+
await writeFile(`${files.pid}.tmp`, `${JSON.stringify(record)}\n`);
|
|
366
|
+
await rename(`${files.pid}.tmp`, files.pid);
|
|
367
|
+
debug('App process spawned; waiting for the start line', { pid: state.pid });
|
|
368
|
+
}
|
|
369
|
+
if (state.exited !== undefined)
|
|
370
|
+
throw await failure(`The application exited with ${state.exited}`);
|
|
371
|
+
if (state.failed !== undefined)
|
|
372
|
+
throw await failure(`The application could not be started: ${state.failed}`);
|
|
373
|
+
if (state.ready && recorded !== undefined)
|
|
374
|
+
break;
|
|
375
|
+
}
|
|
376
|
+
if (recorded === undefined) {
|
|
377
|
+
// The writer never started the app: do not leave it (or a late app) behind. The writer
|
|
378
|
+
// leads its own process group (detached), so the group signal reaches both.
|
|
379
|
+
process.kill(-writerPid, 'SIGKILL');
|
|
380
|
+
throw await failure(`The log writer did not start the application within ${waitMs} ms`);
|
|
381
|
+
}
|
|
382
|
+
const ready = state.ready === true;
|
|
383
|
+
if (!ready)
|
|
384
|
+
warnings.push(`"${STARTED}" was not logged within ${waitMs} ms; the process is running but may not be ready`);
|
|
385
|
+
return {
|
|
386
|
+
pid: recorded,
|
|
387
|
+
log: files.log,
|
|
388
|
+
logWriter: writerPid,
|
|
389
|
+
logMaxBytes: maxBytes,
|
|
390
|
+
logFiles: keep,
|
|
391
|
+
ready,
|
|
392
|
+
ms: Math.round(performance.now() - began),
|
|
393
|
+
warnings,
|
|
394
|
+
};
|
|
395
|
+
}
|
|
396
|
+
finally {
|
|
397
|
+
// The writer and the app outlive this command.
|
|
398
|
+
if (writer?.connected)
|
|
399
|
+
writer.disconnect();
|
|
400
|
+
writer?.unref();
|
|
401
|
+
await rm(files.lock, { force: true });
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
export async function stop(root, timeoutMs = 30_000, force = false) {
|
|
405
|
+
daemonOnly();
|
|
406
|
+
const files = paths(root);
|
|
407
|
+
const record = await readRecord(root);
|
|
408
|
+
if (!record)
|
|
409
|
+
return { stopped: false };
|
|
410
|
+
if (!(await live(record))) {
|
|
411
|
+
await rm(files.pid, { force: true });
|
|
412
|
+
return { stopped: false, pid: record.pid, stale: true };
|
|
413
|
+
}
|
|
414
|
+
const began = performance.now();
|
|
415
|
+
const gone = async (alive, limit) => {
|
|
416
|
+
const until = performance.now() + limit;
|
|
417
|
+
while (performance.now() < until) {
|
|
418
|
+
if (!(await alive()))
|
|
419
|
+
return true;
|
|
420
|
+
await delay(100);
|
|
421
|
+
}
|
|
422
|
+
return false;
|
|
423
|
+
};
|
|
424
|
+
const app = async () => Boolean(await live(record));
|
|
425
|
+
// SIGTERM goes to the app (core drain); the log writer exits once the app's output ends.
|
|
426
|
+
debug('Sending SIGTERM', { pid: record.pid, timeoutMs });
|
|
427
|
+
process.kill(record.pid, 'SIGTERM');
|
|
428
|
+
let forced = false;
|
|
429
|
+
if (!(await gone(app, timeoutMs))) {
|
|
430
|
+
if (!force)
|
|
431
|
+
throw new CliError('STOP_TIMEOUT', `pid ${record.pid} did not stop within ${timeoutMs} ms; it is still draining or stuck (use --force)`);
|
|
432
|
+
debug('Stop timed out; sending SIGKILL', { pid: record.pid });
|
|
433
|
+
process.kill(record.pid, 'SIGKILL');
|
|
434
|
+
forced = true;
|
|
435
|
+
if (!(await gone(app, 5000)))
|
|
436
|
+
throw new CliError('STOP_TIMEOUT', `pid ${record.pid} survived SIGKILL`);
|
|
437
|
+
}
|
|
438
|
+
await rm(files.pid, { force: true });
|
|
439
|
+
const warnings = [];
|
|
440
|
+
if (record.logWriter !== undefined && !(await gone(() => writerAlive(record), 5000))) {
|
|
441
|
+
debug('Log writer still running after the app stopped', { writer: record.logWriter });
|
|
442
|
+
warnings.push(`the log writer (pid ${record.logWriter}) was still running 5 s after the app stopped; a process the app started may hold its output`);
|
|
443
|
+
}
|
|
444
|
+
return {
|
|
445
|
+
stopped: true,
|
|
446
|
+
pid: record.pid,
|
|
447
|
+
ms: Math.round(performance.now() - began),
|
|
448
|
+
forced,
|
|
449
|
+
...(warnings.length ? { warnings } : {}),
|
|
450
|
+
};
|
|
451
|
+
}
|
|
452
|
+
/**
|
|
453
|
+
* Prints the last `lines` lines (across app.log and its rotated files); with `follow`, keeps
|
|
454
|
+
* printing what is appended until SIGINT (or `signal`), following app.log across rotations.
|
|
455
|
+
* The follower remembers the inode of the file it reads. When app.log has become another file,
|
|
456
|
+
* it finishes its file, then reads each rotated file newer than it (found by inode, oldest
|
|
457
|
+
* first) before following the new app.log, so several rotations between two polls skip no line.
|
|
458
|
+
*/
|
|
459
|
+
export async function logs(root, lines = 100, follow = false, write = (text) => process.stdout.write(text), signal) {
|
|
460
|
+
const file = paths(root).log;
|
|
461
|
+
if (!follow) {
|
|
462
|
+
if (!existsSync(file))
|
|
463
|
+
throw new CliError('NO_LOG', `${RUN}/app.log does not exist; the app was never started`);
|
|
464
|
+
write(await tail(file, lines));
|
|
465
|
+
return;
|
|
466
|
+
}
|
|
467
|
+
const stopped = new AbortController();
|
|
468
|
+
const onInt = () => stopped.abort();
|
|
469
|
+
process.once('SIGINT', onInt);
|
|
470
|
+
signal?.addEventListener('abort', onInt, { once: true });
|
|
471
|
+
const follower = new LogFollower(file, write, stopped.signal);
|
|
472
|
+
try {
|
|
473
|
+
// app.log is opened first and the tail ends where following starts: no line falls between.
|
|
474
|
+
write(await tail(file, lines, await follower.open()));
|
|
475
|
+
await follower.run();
|
|
476
|
+
}
|
|
477
|
+
finally {
|
|
478
|
+
process.off('SIGINT', onInt);
|
|
479
|
+
await follower.close();
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
/** `logs -f` (D-0053): reads app.log as it grows and follows it across rotations by inode. */
|
|
483
|
+
class LogFollower {
|
|
484
|
+
file;
|
|
485
|
+
write;
|
|
486
|
+
signal;
|
|
487
|
+
#handle;
|
|
488
|
+
/** The inode of the file being read. */
|
|
489
|
+
#reading;
|
|
490
|
+
#position = 0;
|
|
491
|
+
constructor(file, write, signal) {
|
|
492
|
+
this.file = file;
|
|
493
|
+
this.write = write;
|
|
494
|
+
this.signal = signal;
|
|
495
|
+
}
|
|
496
|
+
/** Opens app.log at its end; the handle and that end, when it exists. */
|
|
497
|
+
async open() {
|
|
498
|
+
const opened = await this.#open(this.file);
|
|
499
|
+
await this.#use(opened, 'end');
|
|
500
|
+
return opened && { handle: opened.handle, end: opened.size };
|
|
501
|
+
}
|
|
502
|
+
async close() {
|
|
503
|
+
const handle = this.#handle;
|
|
504
|
+
this.#handle = undefined;
|
|
505
|
+
await handle?.close();
|
|
506
|
+
}
|
|
507
|
+
/** Prints what is appended until the signal aborts. */
|
|
508
|
+
async run() {
|
|
509
|
+
while (!this.signal.aborted) {
|
|
510
|
+
await delay(250);
|
|
511
|
+
if (!this.#handle) {
|
|
512
|
+
// Before the first start: follow app.log from its beginning once it exists.
|
|
513
|
+
await this.#use(await this.#open(this.file), 'start');
|
|
514
|
+
await this.#drain();
|
|
515
|
+
continue;
|
|
516
|
+
}
|
|
517
|
+
// Rotated once or more: finish this file (it is complete), then each newer one in turn.
|
|
518
|
+
let current = await this.#inode(this.file);
|
|
519
|
+
while (current !== undefined && current !== this.#reading && !this.signal.aborted) {
|
|
520
|
+
await this.#drain();
|
|
521
|
+
if (!(await this.#advance()))
|
|
522
|
+
break; // a rotation is under way: next poll
|
|
523
|
+
current = await this.#inode(this.file);
|
|
524
|
+
}
|
|
525
|
+
await this.#drain();
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
/** Prints what the open file has beyond the position; the handle reads a renamed file too. */
|
|
529
|
+
async #drain() {
|
|
530
|
+
const handle = this.#handle;
|
|
531
|
+
if (!handle)
|
|
532
|
+
return;
|
|
533
|
+
const { size } = await handle.stat();
|
|
534
|
+
if (size < this.#position)
|
|
535
|
+
this.#position = 0; // truncated: read it again from the start
|
|
536
|
+
while (this.#position < size) {
|
|
537
|
+
const buffer = Buffer.alloc(Math.min(size - this.#position, MB));
|
|
538
|
+
const { bytesRead } = await handle.read(buffer, 0, buffer.length, this.#position);
|
|
539
|
+
if (!bytesRead)
|
|
540
|
+
break;
|
|
541
|
+
this.write(buffer.subarray(0, bytesRead).toString('utf8'));
|
|
542
|
+
this.#position += bytesRead;
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
/**
|
|
546
|
+
* Moves to the file written after the one being read; false when it cannot be told yet.
|
|
547
|
+
* The writer renames the oldest first (`.k` → `.k+1` before `.k-1` → `.k`), so `.k-1`
|
|
548
|
+
* opened while `.k` holds our inode both before and after the open is the next file;
|
|
549
|
+
* otherwise a rotation ran meanwhile and the place is looked up again. A gap that stays
|
|
550
|
+
* (a rotated file removed by hand) is stepped over to the next newer file there is.
|
|
551
|
+
*/
|
|
552
|
+
async #advance() {
|
|
553
|
+
const attempts = 20;
|
|
554
|
+
for (let attempt = 0; attempt < attempts && !this.signal.aborted; attempt++) {
|
|
555
|
+
if (attempt)
|
|
556
|
+
await delay(5);
|
|
557
|
+
const rotated = await this.#rotated();
|
|
558
|
+
const index = await this.#indexOf(rotated, this.#reading);
|
|
559
|
+
if (index === undefined) {
|
|
560
|
+
if (await this.#removed())
|
|
561
|
+
return this.#skipToOldest(rotated);
|
|
562
|
+
continue; // moved up while we looked: look again
|
|
563
|
+
}
|
|
564
|
+
// A gap at `.k-1` lasts microseconds during a rotation; one seen for ~100 ms is a hole.
|
|
565
|
+
const target = attempt < attempts - 1 ? index - 1 : (rotated.filter((each) => each < index).at(-1) ?? 0);
|
|
566
|
+
const next = await this.#open(this.#named(target));
|
|
567
|
+
if (!next)
|
|
568
|
+
continue; // `.k-1` is between its rename and the next one
|
|
569
|
+
if ((await this.#inode(this.#named(index))) === this.#reading) {
|
|
570
|
+
if (debugEnabled())
|
|
571
|
+
debug('App log rotated; following the next file', { file: this.#named(target) });
|
|
572
|
+
await this.#use(next, 'start');
|
|
573
|
+
return true;
|
|
574
|
+
}
|
|
575
|
+
await next.handle.close();
|
|
576
|
+
}
|
|
577
|
+
return false;
|
|
578
|
+
}
|
|
579
|
+
/** The followed file was removed beyond --log-files: lines between are gone from disk too. */
|
|
580
|
+
async #skipToOldest(rotated) {
|
|
581
|
+
const oldest = rotated.at(-1) ?? 0;
|
|
582
|
+
const opened = await this.#open(this.#named(oldest));
|
|
583
|
+
if (!opened)
|
|
584
|
+
return false;
|
|
585
|
+
debug('Followed log file was removed by rotation; continuing with the oldest kept file', {
|
|
586
|
+
file: this.#named(oldest),
|
|
587
|
+
});
|
|
588
|
+
this.write('--- dokebi: logs -f fell behind; rotated lines removed before they were read ---\n');
|
|
589
|
+
await this.#use(opened, 'start');
|
|
590
|
+
return true;
|
|
591
|
+
}
|
|
592
|
+
/** The index of the rotated file with `inode` (`app.log.<index>`), when one has it. */
|
|
593
|
+
async #indexOf(rotated, inode) {
|
|
594
|
+
for (const index of rotated)
|
|
595
|
+
if ((await this.#inode(this.#named(index))) === inode)
|
|
596
|
+
return index;
|
|
597
|
+
return undefined;
|
|
598
|
+
}
|
|
599
|
+
/** Indexes of the rotated files there are, ascending (newest first). */
|
|
600
|
+
async #rotated() {
|
|
601
|
+
const name = basename(this.file);
|
|
602
|
+
const names = await readdir(dirname(this.file)).catch((error) => {
|
|
603
|
+
if (!isMissing(error))
|
|
604
|
+
reportError('LOG_READ_FAILED', 'Unable to list the rotated app logs', error, 5000);
|
|
605
|
+
return [];
|
|
606
|
+
});
|
|
607
|
+
return names
|
|
608
|
+
.filter((each) => each.startsWith(`${name}.`) && /^\d+$/.test(each.slice(name.length + 1)))
|
|
609
|
+
.map((each) => Number(each.slice(name.length + 1)))
|
|
610
|
+
.sort((a, b) => a - b);
|
|
611
|
+
}
|
|
612
|
+
/** True when the open file has no name left (removed beyond --log-files). */
|
|
613
|
+
async #removed() {
|
|
614
|
+
return (await this.#handle?.stat())?.nlink === 0;
|
|
615
|
+
}
|
|
616
|
+
/** `app.log.<index>` (`app.log` for 0). */
|
|
617
|
+
#named(index) {
|
|
618
|
+
return index ? `${this.file}.${index}` : this.file;
|
|
619
|
+
}
|
|
620
|
+
/** The inode at `name`, or undefined while it does not exist. */
|
|
621
|
+
async #inode(name) {
|
|
622
|
+
return stat(name).then((stats) => stats.ino, (error) => {
|
|
623
|
+
// Between the rename and the new file (rotation) or before the first start: absent.
|
|
624
|
+
if (!isMissing(error))
|
|
625
|
+
reportError('LOG_READ_FAILED', 'Unable to read the app log', error, 5000);
|
|
626
|
+
return undefined;
|
|
627
|
+
});
|
|
628
|
+
}
|
|
629
|
+
async #open(name) {
|
|
630
|
+
let handle;
|
|
631
|
+
try {
|
|
632
|
+
handle = await open(name, 'r');
|
|
633
|
+
const { ino, size } = await handle.stat();
|
|
634
|
+
return { handle, ino, size };
|
|
635
|
+
}
|
|
636
|
+
catch (error) {
|
|
637
|
+
await handle?.close();
|
|
638
|
+
if (!isMissing(error))
|
|
639
|
+
reportError('LOG_READ_FAILED', 'Unable to open the app log', error, 5000);
|
|
640
|
+
return undefined;
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
/** Reads `opened` from its start or end from now on, closing the file read so far. */
|
|
644
|
+
async #use(opened, from) {
|
|
645
|
+
if (!opened)
|
|
646
|
+
return;
|
|
647
|
+
await this.#handle?.close();
|
|
648
|
+
this.#handle = opened.handle;
|
|
649
|
+
this.#reading = opened.ino;
|
|
650
|
+
this.#position = from === 'end' ? opened.size : 0;
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
/** Runs the app in the foreground and forwards SIGINT/SIGTERM; resolves with its exit code. */
|
|
654
|
+
export async function foreground(root) {
|
|
655
|
+
const entry = await checkProject(root);
|
|
656
|
+
const child = spawn(process.execPath, [entry], {
|
|
657
|
+
cwd: root,
|
|
658
|
+
stdio: 'inherit',
|
|
659
|
+
env: environment('production'),
|
|
660
|
+
});
|
|
661
|
+
const forward = (signal) => () => child.kill(signal);
|
|
662
|
+
const onInt = forward('SIGINT');
|
|
663
|
+
const onTerm = forward('SIGTERM');
|
|
664
|
+
process.on('SIGINT', onInt);
|
|
665
|
+
process.on('SIGTERM', onTerm);
|
|
666
|
+
try {
|
|
667
|
+
return await new Promise((resolve) => child.once('exit', (code, signal) => resolve(code ?? (signal ? 128 : 1))));
|
|
668
|
+
}
|
|
669
|
+
finally {
|
|
670
|
+
process.off('SIGINT', onInt);
|
|
671
|
+
process.off('SIGTERM', onTerm);
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
/** The installed version of a package, looked up like Node does (node_modules up the tree). */
|
|
675
|
+
function installedVersion(root, name) {
|
|
676
|
+
for (let directory = root;; directory = dirname(directory)) {
|
|
677
|
+
try {
|
|
678
|
+
const { version } = JSON.parse(readFileSync(join(directory, 'node_modules', name, 'package.json'), 'utf8'));
|
|
679
|
+
const parts = /^(\d+)\.(\d+)\.(\d+)/.exec(String(version));
|
|
680
|
+
return parts ? [Number(parts[1]), Number(parts[2]), Number(parts[3])] : undefined;
|
|
681
|
+
}
|
|
682
|
+
catch (error) {
|
|
683
|
+
// Not installed here: keep walking up like Node's resolution does.
|
|
684
|
+
if (!isMissing(error))
|
|
685
|
+
reportError('PACKAGE_READ_FAILED', `Unable to read the installed ${name} package.json`, error);
|
|
686
|
+
}
|
|
687
|
+
if (dirname(directory) === directory)
|
|
688
|
+
return undefined;
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
/** template-inertia 0.1.2 serves views from the Vite dev server with HMR (D-0045). */
|
|
692
|
+
function inertiaDevServer(root) {
|
|
693
|
+
const version = installedVersion(root, '@dokebi/template-inertia');
|
|
694
|
+
if (!version)
|
|
695
|
+
return false;
|
|
696
|
+
const [major, minor, patch] = version;
|
|
697
|
+
return major > 0 || minor > 1 || (minor === 1 && patch >= 2);
|
|
698
|
+
}
|
|
699
|
+
/** Files the Vite dev server owns: a change reaches the browser by HMR, not by a restart. */
|
|
700
|
+
const VIEW_FILE = /(^|[\\/])views[\\/].*\.(js|mjs|ts)$|\.(vue|css|scss|sass|less)$/;
|
|
701
|
+
/**
|
|
702
|
+
* Development loop: runs the app, restarts it (SIGTERM, drain, start) 300 ms after a change in
|
|
703
|
+
* src/, dokebi.yaml or .env. With a Vite config, Inertia views come from the Vite dev server
|
|
704
|
+
* inside the app (HMR, D-0045) when template-inertia supports it, otherwise from watch builds
|
|
705
|
+
* that restart the app. The first Ctrl+C drains everything; a second one kills.
|
|
706
|
+
*/
|
|
707
|
+
export async function dev(root, say) {
|
|
708
|
+
const entry = await checkProject(root);
|
|
709
|
+
let app;
|
|
710
|
+
let exiting = false;
|
|
711
|
+
const hasVite = ['vite.config.ts', 'vite.config.mjs', 'vite.config.js'].some((name) => existsSync(join(root, name)));
|
|
712
|
+
const hot = hasVite && inertiaDevServer(root);
|
|
713
|
+
if (hot)
|
|
714
|
+
say('Inertia views: Vite dev server with hot module replacement');
|
|
715
|
+
const guard = new URL('./dev-guard.js', import.meta.url).href;
|
|
716
|
+
// stdio fd 3 is the IPC channel the guard watches.
|
|
717
|
+
const child = (args) => spawn(process.execPath, ['--import', guard, ...args], {
|
|
718
|
+
cwd: root,
|
|
719
|
+
stdio: ['inherit', 'inherit', 'inherit', 'ipc'],
|
|
720
|
+
// DOKEBI_DEV: a cluster app runs one worker (@dokebi/cluster, DOKEBI_DEV_WORKERS for more).
|
|
721
|
+
env: {
|
|
722
|
+
...environment('development'),
|
|
723
|
+
DOKEBI_DEV: '1',
|
|
724
|
+
...(hot ? { DOKEBI_VITE_DEV: '1' } : {}),
|
|
725
|
+
},
|
|
726
|
+
});
|
|
727
|
+
const run = () => {
|
|
728
|
+
app = child([entry]);
|
|
729
|
+
const current = app;
|
|
730
|
+
current.once('exit', (code, signal) => {
|
|
731
|
+
if (app === current && !exiting)
|
|
732
|
+
say(`app exited (${signal ?? code}); waiting for a change to restart`);
|
|
733
|
+
});
|
|
734
|
+
};
|
|
735
|
+
const halt = async (target, limitMs = 15_000) => {
|
|
736
|
+
if (!target || target.exitCode !== null || target.signalCode !== null)
|
|
737
|
+
return;
|
|
738
|
+
const exited = new Promise((resolve) => target.once('exit', resolve));
|
|
739
|
+
target.kill('SIGTERM');
|
|
740
|
+
const timer = setTimeout(() => target.kill('SIGKILL'), limitMs);
|
|
741
|
+
await exited;
|
|
742
|
+
clearTimeout(timer);
|
|
743
|
+
};
|
|
744
|
+
// Vite builds in watch mode beside the app. The app starts after every first build (a build
|
|
745
|
+
// empties dist/ first, and the SSR bundle is read once at start) and restarts after rebuilds.
|
|
746
|
+
const helpers = [];
|
|
747
|
+
const firstBuilds = [];
|
|
748
|
+
let built = () => { };
|
|
749
|
+
const vite = join(root, 'node_modules/vite/dist/node/index.js');
|
|
750
|
+
const watchScript = fileURLToPath(new URL('./vite-watch.js', import.meta.url));
|
|
751
|
+
for (const [config, label] of [
|
|
752
|
+
[['vite.config.ts', 'vite.config.mjs', 'vite.config.js'], 'vite'],
|
|
753
|
+
[['vite.ssr.config.ts', 'vite.ssr.config.mjs', 'vite.ssr.config.js'], 'vite ssr'],
|
|
754
|
+
]) {
|
|
755
|
+
const file = config.find((name) => existsSync(join(root, name)));
|
|
756
|
+
if (hot || !file || !existsSync(vite))
|
|
757
|
+
continue;
|
|
758
|
+
say(`${label}: watching (${file})`);
|
|
759
|
+
const helper = child([watchScript, file]);
|
|
760
|
+
helpers.push(helper);
|
|
761
|
+
let first = true;
|
|
762
|
+
let resolveFirst;
|
|
763
|
+
let rejectFirst;
|
|
764
|
+
firstBuilds.push(new Promise((resolve, reject) => {
|
|
765
|
+
resolveFirst = resolve;
|
|
766
|
+
rejectFirst = reject;
|
|
767
|
+
}));
|
|
768
|
+
helper.on('message', (message) => {
|
|
769
|
+
if (message?.failed)
|
|
770
|
+
say(`${label} build failed; fix it and save again`);
|
|
771
|
+
if (!message?.built)
|
|
772
|
+
return;
|
|
773
|
+
if (first) {
|
|
774
|
+
first = false;
|
|
775
|
+
resolveFirst();
|
|
776
|
+
}
|
|
777
|
+
else
|
|
778
|
+
built(label);
|
|
779
|
+
});
|
|
780
|
+
helper.once('exit', (code, signal) => {
|
|
781
|
+
rejectFirst(new CliError('DEV_BUILD_FAILED', `${label} exited (${signal ?? code})`));
|
|
782
|
+
if (!exiting)
|
|
783
|
+
say(`${label} exited (${signal ?? code})`);
|
|
784
|
+
});
|
|
785
|
+
}
|
|
786
|
+
if (firstBuilds.length)
|
|
787
|
+
say('waiting for the first builds');
|
|
788
|
+
try {
|
|
789
|
+
await Promise.all(firstBuilds);
|
|
790
|
+
}
|
|
791
|
+
catch (error) {
|
|
792
|
+
exiting = true;
|
|
793
|
+
await Promise.all(helpers.map((helper) => halt(helper, 5000)));
|
|
794
|
+
throw error;
|
|
795
|
+
}
|
|
796
|
+
run();
|
|
797
|
+
let timer;
|
|
798
|
+
let restarting = Promise.resolve();
|
|
799
|
+
const changed = (what) => {
|
|
800
|
+
if (exiting)
|
|
801
|
+
return;
|
|
802
|
+
clearTimeout(timer);
|
|
803
|
+
timer = setTimeout(() => {
|
|
804
|
+
restarting = restarting
|
|
805
|
+
.then(async () => {
|
|
806
|
+
if (exiting)
|
|
807
|
+
return;
|
|
808
|
+
say(`${what} changed; restarting`);
|
|
809
|
+
const previous = app;
|
|
810
|
+
app = undefined;
|
|
811
|
+
await halt(previous);
|
|
812
|
+
if (!exiting)
|
|
813
|
+
run();
|
|
814
|
+
})
|
|
815
|
+
.catch((error) => {
|
|
816
|
+
// Keep the loop alive: the next change tries again.
|
|
817
|
+
reportError('DEV_RESTART_FAILED', 'Restarting the app failed', error);
|
|
818
|
+
});
|
|
819
|
+
}, 300);
|
|
820
|
+
};
|
|
821
|
+
built = (label) => changed(`${label} build`);
|
|
822
|
+
const watchers = [
|
|
823
|
+
watch(join(root, 'src'), { recursive: true }, (_event, name) => {
|
|
824
|
+
// Styles and components reach the app through the Vite builds (restart after the build).
|
|
825
|
+
if (hot && name && VIEW_FILE.test(name))
|
|
826
|
+
return;
|
|
827
|
+
if (helpers.length && name && /\.(vue|css|scss|sass|less)$/.test(name))
|
|
828
|
+
return;
|
|
829
|
+
changed(`src/${name ?? ''}`);
|
|
830
|
+
}),
|
|
831
|
+
watch(root, (_event, name) => {
|
|
832
|
+
if (name === 'dokebi.yaml' || name === '.env')
|
|
833
|
+
changed(name);
|
|
834
|
+
}),
|
|
835
|
+
];
|
|
836
|
+
return await new Promise((resolve) => {
|
|
837
|
+
const finish = async () => {
|
|
838
|
+
if (exiting) {
|
|
839
|
+
app?.kill('SIGKILL');
|
|
840
|
+
for (const helper of helpers)
|
|
841
|
+
helper.kill('SIGKILL');
|
|
842
|
+
resolve(130);
|
|
843
|
+
return;
|
|
844
|
+
}
|
|
845
|
+
exiting = true;
|
|
846
|
+
say('stopping (Ctrl+C again to kill)');
|
|
847
|
+
clearTimeout(timer);
|
|
848
|
+
for (const watcher of watchers)
|
|
849
|
+
watcher.close();
|
|
850
|
+
await restarting;
|
|
851
|
+
await Promise.all([halt(app), ...helpers.map((helper) => halt(helper, 5000))]);
|
|
852
|
+
resolve(0);
|
|
853
|
+
};
|
|
854
|
+
process.on('SIGINT', () => void finish());
|
|
855
|
+
process.on('SIGTERM', () => void finish());
|
|
856
|
+
});
|
|
857
|
+
}
|