@dokebi/cli 0.2.5 → 0.4.1
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 +181 -87
- package/README.md +55 -24
- package/catalog.json +2203 -0
- package/dist/app-urls.d.ts +21 -0
- package/dist/app-urls.js +94 -0
- package/dist/banner-art.d.ts +4 -0
- package/dist/banner-art.js +31 -0
- package/dist/banner.d.ts +25 -0
- package/dist/banner.js +202 -0
- package/dist/bin.d.ts +2 -1
- package/dist/bin.js +475 -46
- package/dist/catalog.d.ts +161 -0
- package/dist/catalog.js +110 -0
- package/dist/check/form-rules.d.ts +13 -0
- package/dist/check/form-rules.js +224 -0
- package/dist/check/index.d.ts +23 -0
- package/dist/check/index.js +70 -0
- package/dist/check/packages.d.ts +17 -0
- package/dist/check/packages.js +161 -0
- package/dist/check/problems.d.ts +19 -0
- package/dist/check/problems.js +22 -0
- package/dist/check/project-rules.d.ts +10 -0
- package/dist/check/project-rules.js +142 -0
- package/dist/check/source-rules.d.ts +28 -0
- package/dist/check/source-rules.js +273 -0
- package/dist/check/typescript.d.ts +46 -0
- package/dist/check/typescript.js +168 -0
- package/dist/commands.d.ts +18 -0
- package/dist/commands.js +372 -0
- package/dist/config-edit.d.ts +5 -4
- package/dist/config-edit.js +9 -8
- package/dist/configuration.d.ts +1 -0
- package/dist/configuration.js +2 -1
- package/dist/dev-infra.d.ts +62 -0
- package/dist/dev-infra.js +215 -0
- package/dist/env-file.d.ts +24 -0
- package/dist/env-file.js +100 -0
- package/dist/generator.d.ts +24 -1
- package/dist/generator.js +57 -47
- package/dist/guide.d.ts +29 -0
- package/dist/guide.js +90 -0
- package/dist/install-plan.d.ts +8 -0
- package/dist/install-plan.js +21 -4
- package/dist/layout.d.ts +5 -4
- package/dist/layout.js +8 -7
- package/dist/log-writer-control.d.ts +55 -0
- package/dist/log-writer-control.js +327 -0
- package/dist/log-writer.js +53 -12
- package/dist/manifest.d.ts +28 -0
- package/dist/manifest.js +77 -2
- package/dist/npm-client.d.ts +49 -0
- package/dist/npm-client.js +162 -41
- package/dist/output.d.ts +1 -1
- package/dist/output.js +14 -8
- package/dist/plugin-lifecycle.d.ts +7 -0
- package/dist/plugin-lifecycle.js +21 -3
- package/dist/plugins-command.d.ts +47 -0
- package/dist/plugins-command.js +189 -0
- package/dist/project-guide.d.ts +36 -1
- package/dist/project-guide.js +142 -18
- package/dist/project-inspector.d.ts +10 -0
- package/dist/project-inspector.js +51 -2
- package/dist/project-state.d.ts +13 -2
- package/dist/project-state.js +8 -6
- package/dist/project.d.ts +89 -7
- package/dist/project.js +835 -85
- package/dist/recipe-record.d.ts +24 -0
- package/dist/recipe-record.js +67 -0
- package/dist/recipe.d.ts +120 -0
- package/dist/recipe.js +801 -0
- package/dist/registry.d.ts +79 -0
- package/dist/registry.js +212 -0
- package/dist/rotating-log.d.ts +12 -0
- package/dist/rotating-log.js +19 -0
- package/dist/server.d.ts +94 -4
- package/dist/server.js +624 -64
- package/dist/verify.d.ts +62 -0
- package/dist/verify.js +380 -0
- package/dist/vite-host-process.d.ts +20 -0
- package/dist/vite-host-process.js +117 -0
- package/package.json +30 -6
package/dist/server.js
CHANGED
|
@@ -4,17 +4,46 @@
|
|
|
4
4
|
// `.dokebi/run/app.log` and rotates it by size (`app.log.1` … `app.log.<files>`). A pid is only
|
|
5
5
|
// acted on when `ps` (/proc on Linux) shows it runs this project's entry and started when the pid
|
|
6
6
|
// file says, so a reused pid is never signalled.
|
|
7
|
+
//
|
|
8
|
+
// Two ways to stop (D-0065 §11):
|
|
9
|
+
// - `stop` (the `dokebi stop` command) acts on a pid file that may be days old: its pid may have
|
|
10
|
+
// been reused by an unrelated process, so it signals only after `ps` (/proc) confirms the
|
|
11
|
+
// command and start time. Without process information (a sandbox that blocks `ps`, D-0073) it
|
|
12
|
+
// asks the app instead: `start` gives every app a random token (DOKEBI_INSTANCE_TOKEN, kept in
|
|
13
|
+
// the pid file) and @dokebi/web answers a request carrying a challenge in `x-dokebi-instance`
|
|
14
|
+
// with HMAC-SHA256(token, challenge). Only a correct answer at the recorded address proves the
|
|
15
|
+
// pid is ours; otherwise it refuses with PROCESS_INFO_FAILED and keeps the pid file. `status`
|
|
16
|
+
// reports which proof it used (`proof: process | http | log-writer`).
|
|
17
|
+
// An agent sandbox per command (Codex CLI, AI-M03) may refuse even the signal: the app was
|
|
18
|
+
// started in another sandbox instance. The log writer, the app's parent from that instance,
|
|
19
|
+
// listens on a loopback control port (log-writer-control.ts; `control` in the pid file); after
|
|
20
|
+
// both sides prove the instance token, it drain-stops the app (`via: 'log-writer'`). Without ps it is asked first
|
|
21
|
+
// (`proof: 'log-writer'`), then the HTTP proof; an unproven instance is never signalled or asked.
|
|
22
|
+
// - `stopStarted` (used by `dokebi verify`) stops the process this same CLI run started seconds
|
|
23
|
+
// ago through the pids `start` returned: identity is known from the handle, so it needs no
|
|
24
|
+
// `ps` (liveness is `kill(pid, 0)`), and it works in agent sandboxes that block `ps`.
|
|
25
|
+
//
|
|
26
|
+
// Every app the CLI runs gets the project's `.env` under its own environment (D-0073).
|
|
7
27
|
import { execFile, spawn } from 'node:child_process';
|
|
28
|
+
import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
|
|
8
29
|
import { existsSync, readFileSync, watch } from 'node:fs';
|
|
9
30
|
import { mkdir, open, readdir, readFile, rename, rm, stat, writeFile, } from 'node:fs/promises';
|
|
31
|
+
import { request as httpRequest } from 'node:http';
|
|
32
|
+
import { request as httpsRequest } from 'node:https';
|
|
10
33
|
import { basename, dirname, join } from 'node:path';
|
|
11
34
|
import { setTimeout as delay } from 'node:timers/promises';
|
|
12
35
|
import { fileURLToPath } from 'node:url';
|
|
13
36
|
import { promisify } from 'node:util';
|
|
14
|
-
import {
|
|
37
|
+
import { appUrls, readAppHosts } from './app-urls.js';
|
|
38
|
+
import { CONFIG_FILE, parseProjectConfig } from './configuration.js';
|
|
39
|
+
import { debug, debugEnabled, describeError, isMissing, reportError } from './diagnostics.js';
|
|
40
|
+
import { appEnvironment, readEnvFile } from './env-file.js';
|
|
15
41
|
import { CliError } from './errors.js';
|
|
42
|
+
import { askLogWriter, CONTROL_HOST } from './log-writer-control.js';
|
|
43
|
+
import { redactNpmOutput } from './npm-client.js';
|
|
16
44
|
import { ProjectInspector } from './project-inspector.js';
|
|
17
45
|
import { logFiles } from './rotating-log.js';
|
|
46
|
+
import { ViteHostProcess, viteHostScript } from './vite-host-process.js';
|
|
18
47
|
const exec = promisify(execFile);
|
|
19
48
|
const ENTRY = 'src/main.ts';
|
|
20
49
|
const RUN = '.dokebi/run';
|
|
@@ -26,6 +55,9 @@ export const LOG_LIMITS = {
|
|
|
26
55
|
files: { default: 5, min: 1, max: 100 },
|
|
27
56
|
};
|
|
28
57
|
const MB = 1024 * 1024;
|
|
58
|
+
const signalProcess = (pid, signal) => {
|
|
59
|
+
process.kill(pid, signal);
|
|
60
|
+
};
|
|
29
61
|
const paths = (root) => ({
|
|
30
62
|
run: join(root, RUN),
|
|
31
63
|
pid: join(root, RUN, 'app.pid'),
|
|
@@ -60,6 +92,18 @@ async function readRecord(root) {
|
|
|
60
92
|
}
|
|
61
93
|
return undefined;
|
|
62
94
|
}
|
|
95
|
+
function processInfoFailed(pid, source, cause) {
|
|
96
|
+
return new CliError('PROCESS_INFO_FAILED', `Unable to read process information of pid ${pid} with ${source}; the pid file is kept because the process may still run`, {
|
|
97
|
+
cause,
|
|
98
|
+
details: {
|
|
99
|
+
pid,
|
|
100
|
+
source,
|
|
101
|
+
// A sandbox that blocks ps (or /proc) is the usual cause: say how to go on (AI-M01).
|
|
102
|
+
hint: 'run this command outside the sandbox, or allow ps (macOS) or /proc (Linux) for it',
|
|
103
|
+
manualStop: `check the process yourself (ps -p ${pid} -o command=); if it runs this project's src/main.ts, stop it with kill -TERM ${pid} (drain), then remove .dokebi/run/app.pid`,
|
|
104
|
+
},
|
|
105
|
+
});
|
|
106
|
+
}
|
|
63
107
|
/**
|
|
64
108
|
* Linux reads /proc: slim container images (node:*-slim) have no `ps`. Start time is the boot
|
|
65
109
|
* time plus the process start in clock ticks (USER_HZ, 100 on Linux).
|
|
@@ -77,7 +121,7 @@ async function procInfo(pid) {
|
|
|
77
121
|
const ticks = Number(fields[19]);
|
|
78
122
|
const boot = Number(/^btime (\d+)$/m.exec(uptime)?.[1]);
|
|
79
123
|
if (!Number.isFinite(ticks) || !Number.isFinite(boot))
|
|
80
|
-
|
|
124
|
+
throw processInfoFailed(pid, '/proc', new Error('Unexpected /proc stat content'));
|
|
81
125
|
return {
|
|
82
126
|
startedAt: (boot + ticks / 100) * 1000,
|
|
83
127
|
rssKb: Number(/^VmRSS:\s+(\d+)/m.exec(status)?.[1] ?? 0),
|
|
@@ -85,37 +129,46 @@ async function procInfo(pid) {
|
|
|
85
129
|
};
|
|
86
130
|
}
|
|
87
131
|
catch (error) {
|
|
88
|
-
// ENOENT: no such process. Anything else (permissions, a changed /proc) is
|
|
89
|
-
if (
|
|
90
|
-
|
|
91
|
-
|
|
132
|
+
// ENOENT: no such process. Anything else (permissions, a changed /proc) is a failure.
|
|
133
|
+
if (error instanceof CliError)
|
|
134
|
+
throw error;
|
|
135
|
+
if (isMissing(error))
|
|
136
|
+
return undefined;
|
|
137
|
+
throw processInfoFailed(pid, '/proc', error);
|
|
92
138
|
}
|
|
93
139
|
}
|
|
94
|
-
async
|
|
140
|
+
const readProcess = async (pid) => {
|
|
95
141
|
if (process.platform === 'linux')
|
|
96
142
|
return procInfo(pid);
|
|
143
|
+
let stdout;
|
|
97
144
|
try {
|
|
98
|
-
|
|
145
|
+
({ stdout } = await exec('ps', ['-ww', '-o', 'lstart=,rss=,command=', '-p', String(pid)], {
|
|
99
146
|
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(' ') };
|
|
147
|
+
}));
|
|
108
148
|
}
|
|
109
149
|
catch (error) {
|
|
110
|
-
// ps exits 1 when the pid does not exist; anything else (no ps,
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
150
|
+
// ps exits 1 with no output when the pid does not exist; anything else (no ps, a sandbox
|
|
151
|
+
// that refuses it, killed) means the process state is unknown.
|
|
152
|
+
const failed = error;
|
|
153
|
+
if (failed.code === 1 &&
|
|
154
|
+
!String(failed.stdout ?? '').trim() &&
|
|
155
|
+
!String(failed.stderr ?? '').trim())
|
|
156
|
+
return undefined;
|
|
157
|
+
throw processInfoFailed(pid, 'ps', error);
|
|
114
158
|
}
|
|
115
|
-
|
|
159
|
+
// lstart is a fixed 24-character date: "Thu Oct 15 00:21:26 2026".
|
|
160
|
+
const line = stdout.trim();
|
|
161
|
+
if (!line)
|
|
162
|
+
return undefined;
|
|
163
|
+
const startedAt = Date.parse(line.slice(0, 24));
|
|
164
|
+
const [rss = '', ...command] = line.slice(24).trim().split(/\s+/);
|
|
165
|
+
if (!Number.isFinite(startedAt))
|
|
166
|
+
throw processInfoFailed(pid, 'ps', new Error(`Unexpected ps output: ${line.slice(0, 200)}`));
|
|
167
|
+
return { startedAt, rssKb: Number(rss), command: command.join(' ') };
|
|
168
|
+
};
|
|
116
169
|
/** The recorded process, when it is still alive and still this project's process. */
|
|
117
|
-
async function live(record) {
|
|
118
|
-
const info = await
|
|
170
|
+
async function live(record, read) {
|
|
171
|
+
const info = await read(record.pid);
|
|
119
172
|
if (!info?.command.includes(record.entry)) {
|
|
120
173
|
if (info)
|
|
121
174
|
debug('Recorded pid runs another command; treating it as stale', { pid: record.pid });
|
|
@@ -129,10 +182,10 @@ async function live(record) {
|
|
|
129
182
|
return info;
|
|
130
183
|
}
|
|
131
184
|
/** Whether the record's log writer still runs (its command names the writer and the entry). */
|
|
132
|
-
async function writerAlive(record) {
|
|
185
|
+
async function writerAlive(record, read) {
|
|
133
186
|
if (record.logWriter === undefined)
|
|
134
187
|
return false;
|
|
135
|
-
const info = await
|
|
188
|
+
const info = await read(record.logWriter);
|
|
136
189
|
return Boolean(info?.command.includes('log-writer') && info.command.includes(record.entry));
|
|
137
190
|
}
|
|
138
191
|
async function logInfo(root) {
|
|
@@ -152,14 +205,29 @@ async function logInfo(root) {
|
|
|
152
205
|
const writerBytes = await sizes(writerLog);
|
|
153
206
|
return { logFiles: files, ...(writerBytes ? { logWriterLog: writerLog } : {}) };
|
|
154
207
|
}
|
|
155
|
-
export async function status(root
|
|
208
|
+
export async function status(root,
|
|
209
|
+
/** Tests replace how a process is read; the CLI uses ps (/proc on Linux). */
|
|
210
|
+
read = readProcess,
|
|
211
|
+
/** Tests replace how a process is signalled; the CLI uses process.kill. */
|
|
212
|
+
kill = signalProcess) {
|
|
156
213
|
const { pid, log } = paths(root);
|
|
157
214
|
const record = await readRecord(root);
|
|
158
215
|
const logs = await logInfo(root);
|
|
159
216
|
if (!record)
|
|
160
217
|
return { running: false, log, ...logs };
|
|
161
|
-
|
|
162
|
-
|
|
218
|
+
let info;
|
|
219
|
+
let proof = 'process';
|
|
220
|
+
try {
|
|
221
|
+
info = await live(record, read);
|
|
222
|
+
}
|
|
223
|
+
catch (error) {
|
|
224
|
+
if (!processInfoFailure(error))
|
|
225
|
+
throw error;
|
|
226
|
+
const found = await withoutProcessInfo(root, record, error, kill);
|
|
227
|
+
if (found !== 'gone')
|
|
228
|
+
proof = found;
|
|
229
|
+
}
|
|
230
|
+
if (!info && proof === 'process') {
|
|
163
231
|
await rm(pid, { force: true });
|
|
164
232
|
return {
|
|
165
233
|
running: false,
|
|
@@ -175,14 +243,212 @@ export async function status(root) {
|
|
|
175
243
|
pid: record.pid,
|
|
176
244
|
startedAt: record.startedAt,
|
|
177
245
|
uptimeSec: Math.round((Date.now() - Date.parse(record.startedAt)) / 1000),
|
|
178
|
-
rssMb: Math.round(info.rssKb / 1024),
|
|
246
|
+
...(info ? { rssMb: Math.round(info.rssKb / 1024) } : {}),
|
|
247
|
+
proof,
|
|
179
248
|
log,
|
|
180
249
|
...logs,
|
|
181
250
|
...(record.logWriter !== undefined
|
|
182
|
-
? {
|
|
251
|
+
? {
|
|
252
|
+
logWriter: {
|
|
253
|
+
pid: record.logWriter,
|
|
254
|
+
running: proof === 'process'
|
|
255
|
+
? await writerAlive(record, read)
|
|
256
|
+
: proof === 'log-writer' || pidState(record.logWriter, kill) === 'alive',
|
|
257
|
+
},
|
|
258
|
+
}
|
|
183
259
|
: {}),
|
|
184
260
|
};
|
|
185
261
|
}
|
|
262
|
+
/** The variable `start` gives the app (D-0073); @dokebi/web answers the challenge with it. */
|
|
263
|
+
export const INSTANCE_TOKEN_ENV = 'DOKEBI_INSTANCE_TOKEN';
|
|
264
|
+
/** The challenge request header and the answer response header of @dokebi/web. */
|
|
265
|
+
export const INSTANCE_HEADER = 'x-dokebi-instance';
|
|
266
|
+
/** The answer to a challenge: base64url HMAC-SHA256 keyed with the instance token. */
|
|
267
|
+
export function instanceAnswer(token, challenge) {
|
|
268
|
+
return createHmac('sha256', token).update(challenge).digest('base64url');
|
|
269
|
+
}
|
|
270
|
+
/** `kill(pid, 0)`: ESRCH is gone; EPERM (or a sandbox refusing signals) says nothing for sure. */
|
|
271
|
+
function pidState(pid, kill) {
|
|
272
|
+
try {
|
|
273
|
+
kill(pid, 0);
|
|
274
|
+
return 'alive';
|
|
275
|
+
}
|
|
276
|
+
catch (error) {
|
|
277
|
+
if (error.code === 'ESRCH')
|
|
278
|
+
return 'gone';
|
|
279
|
+
debug('kill(pid, 0) failed; the pid state is unknown', { pid, error: describeError(error) });
|
|
280
|
+
return 'unknown';
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
/** The web address dokebi.yaml configures, for pid files that recorded none. */
|
|
284
|
+
async function configuredUrl(root) {
|
|
285
|
+
let source;
|
|
286
|
+
try {
|
|
287
|
+
source = await readFile(join(root, CONFIG_FILE), 'utf8');
|
|
288
|
+
}
|
|
289
|
+
catch (error) {
|
|
290
|
+
if (!isMissing(error))
|
|
291
|
+
reportError('CONFIG_READ_FAILED', `Unable to read ${CONFIG_FILE}`, error);
|
|
292
|
+
return undefined;
|
|
293
|
+
}
|
|
294
|
+
let web;
|
|
295
|
+
try {
|
|
296
|
+
web = (parseProjectConfig(source).options['dokebi-web'] ?? {});
|
|
297
|
+
}
|
|
298
|
+
catch (error) {
|
|
299
|
+
// doctor reports an invalid dokebi.yaml; here it only means there is no address to try.
|
|
300
|
+
debug('dokebi.yaml could not be parsed; no configured address', {
|
|
301
|
+
error: describeError(error),
|
|
302
|
+
});
|
|
303
|
+
return undefined;
|
|
304
|
+
}
|
|
305
|
+
if (typeof web.port !== 'number' || web.port <= 0)
|
|
306
|
+
return undefined;
|
|
307
|
+
const host = typeof web.host !== 'string' || ['0.0.0.0', '::', '[::]'].includes(web.host)
|
|
308
|
+
? '127.0.0.1'
|
|
309
|
+
: web.host;
|
|
310
|
+
return `http://${host.includes(':') ? `[${host}]` : host}:${web.port}`;
|
|
311
|
+
}
|
|
312
|
+
/** One challenge request; resolves with the answer header (undefined when there is none). */
|
|
313
|
+
function challenge(url, value, timeoutMs) {
|
|
314
|
+
return new Promise((resolve, reject) => {
|
|
315
|
+
const target = new URL(url);
|
|
316
|
+
const https = target.protocol === 'https:';
|
|
317
|
+
const req = (https ? httpsRequest : httpRequest)({
|
|
318
|
+
host: target.hostname.replace(/^\[|\]$/g, ''),
|
|
319
|
+
port: target.port,
|
|
320
|
+
path: '/',
|
|
321
|
+
method: 'GET',
|
|
322
|
+
headers: { [INSTANCE_HEADER]: value },
|
|
323
|
+
timeout: timeoutMs,
|
|
324
|
+
// The HMAC answer is the proof; a development certificate need not be trusted for it.
|
|
325
|
+
...(https ? { rejectUnauthorized: false } : {}),
|
|
326
|
+
}, (response) => {
|
|
327
|
+
response.once('error', reject);
|
|
328
|
+
response.once('end', () => {
|
|
329
|
+
const answer = response.headers[INSTANCE_HEADER];
|
|
330
|
+
resolve(typeof answer === 'string' ? answer : undefined);
|
|
331
|
+
});
|
|
332
|
+
response.resume();
|
|
333
|
+
});
|
|
334
|
+
req.once('timeout', () => req.destroy(new Error(`No answer within ${timeoutMs} ms`)));
|
|
335
|
+
req.once('error', reject);
|
|
336
|
+
req.end();
|
|
337
|
+
});
|
|
338
|
+
}
|
|
339
|
+
/**
|
|
340
|
+
* Whether the app at the recorded address knows the record's instance token (D-0073): a random
|
|
341
|
+
* challenge must come back as HMAC-SHA256(token, challenge). The token never goes on the wire.
|
|
342
|
+
*/
|
|
343
|
+
export async function proveInstance(root, record, timeoutMs = 2000) {
|
|
344
|
+
if (!record.token)
|
|
345
|
+
return {
|
|
346
|
+
proven: false,
|
|
347
|
+
tried: [],
|
|
348
|
+
reason: 'the pid file has no instance token (started by an older CLI)',
|
|
349
|
+
};
|
|
350
|
+
const recorded = record.urls ?? [];
|
|
351
|
+
const configured = recorded.length ? undefined : await configuredUrl(root);
|
|
352
|
+
const urls = [
|
|
353
|
+
...recorded.filter((url) => url.includes('127.0.0.1')),
|
|
354
|
+
...recorded.filter((url) => !url.includes('127.0.0.1')),
|
|
355
|
+
...(configured ? [configured] : []),
|
|
356
|
+
].slice(0, 4);
|
|
357
|
+
if (!urls.length)
|
|
358
|
+
return {
|
|
359
|
+
proven: false,
|
|
360
|
+
tried: [],
|
|
361
|
+
reason: 'no address to ask: the app logged no web listening line and dokebi.yaml has no dokebi-web.port',
|
|
362
|
+
};
|
|
363
|
+
const reasons = [];
|
|
364
|
+
for (const url of urls) {
|
|
365
|
+
const value = randomBytes(24).toString('base64url');
|
|
366
|
+
const expected = Buffer.from(instanceAnswer(record.token, value));
|
|
367
|
+
try {
|
|
368
|
+
const answer = await challenge(url, value, timeoutMs);
|
|
369
|
+
const given = Buffer.from(answer ?? '');
|
|
370
|
+
if (given.length === expected.length && timingSafeEqual(given, expected)) {
|
|
371
|
+
debug('The app proved its instance token', { url });
|
|
372
|
+
return { proven: true, url, tried: urls };
|
|
373
|
+
}
|
|
374
|
+
reasons.push(`${url}: ${answer === undefined ? `answered without ${INSTANCE_HEADER}` : 'wrong answer'}`);
|
|
375
|
+
}
|
|
376
|
+
catch (error) {
|
|
377
|
+
reasons.push(`${url}: ${error instanceof Error ? error.message : String(error)}`);
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
debug('No instance proof', { tried: urls, reasons });
|
|
381
|
+
return { proven: false, tried: urls, reason: reasons.join('; ') };
|
|
382
|
+
}
|
|
383
|
+
const processInfoFailure = (error) => error instanceof CliError && error.code === 'PROCESS_INFO_FAILED';
|
|
384
|
+
/**
|
|
385
|
+
* Asks the record's log writer (its control socket) to do `request` once both sides proved the
|
|
386
|
+
* record's instance token, and checks that the writer's app is the recorded pid.
|
|
387
|
+
*/
|
|
388
|
+
async function askWriter(record, request) {
|
|
389
|
+
if (!record.token)
|
|
390
|
+
return {
|
|
391
|
+
reached: false,
|
|
392
|
+
reason: 'the pid file has no instance token (started by an older CLI)',
|
|
393
|
+
};
|
|
394
|
+
if (record.control === undefined)
|
|
395
|
+
return {
|
|
396
|
+
reached: false,
|
|
397
|
+
reason: 'the pid file names no control port (started by an older CLI, or the log writer could not listen: see .dokebi/run/log-writer.log)',
|
|
398
|
+
};
|
|
399
|
+
const asked = await askLogWriter(record.control, record.token, request);
|
|
400
|
+
if (!asked.reached)
|
|
401
|
+
return asked;
|
|
402
|
+
if (asked.app.pid !== record.pid) {
|
|
403
|
+
await asked.closed(0);
|
|
404
|
+
return {
|
|
405
|
+
reached: false,
|
|
406
|
+
reason: `the log writer runs pid ${asked.app.pid}, not the recorded pid ${record.pid}`,
|
|
407
|
+
};
|
|
408
|
+
}
|
|
409
|
+
return { reached: true, running: asked.app.running, reply: asked.reply, closed: asked.closed };
|
|
410
|
+
}
|
|
411
|
+
/**
|
|
412
|
+
* Without process information: `gone` (no such pid, or its log writer saw it exit),
|
|
413
|
+
* `log-writer` (the app's log writer proved the token on its control socket), `http` (the app
|
|
414
|
+
* proved the token over HTTP), or the original PROCESS_INFO_FAILED with what the proofs found,
|
|
415
|
+
* so an unproven pid is never used.
|
|
416
|
+
*/
|
|
417
|
+
async function withoutProcessInfo(root, record, failure, kill) {
|
|
418
|
+
const state = pidState(record.pid, kill);
|
|
419
|
+
if (state === 'gone') {
|
|
420
|
+
debug('No process information, but the recorded pid does not exist', { pid: record.pid });
|
|
421
|
+
return 'gone';
|
|
422
|
+
}
|
|
423
|
+
// The log writer first: it needs no web plugin, and it can stop the app when this shell may
|
|
424
|
+
// not signal it (an agent sandbox per command, AI-M03).
|
|
425
|
+
const writer = await askWriter(record, { op: 'status' });
|
|
426
|
+
if (writer.reached) {
|
|
427
|
+
debug('The log writer proved the instance token', { pid: record.pid, running: writer.running });
|
|
428
|
+
return writer.running ? 'log-writer' : 'gone';
|
|
429
|
+
}
|
|
430
|
+
const tried = record.control !== undefined ? [`tcp://${CONTROL_HOST}:${record.control}`] : [];
|
|
431
|
+
const reasons = [`log writer: ${writer.reason}`];
|
|
432
|
+
if (state === 'alive') {
|
|
433
|
+
const proof = await proveInstance(root, record);
|
|
434
|
+
if (proof.proven)
|
|
435
|
+
return 'http';
|
|
436
|
+
tried.push(...proof.tried);
|
|
437
|
+
if (proof.reason)
|
|
438
|
+
reasons.push(`http: ${proof.reason}`);
|
|
439
|
+
}
|
|
440
|
+
// kill(pid, 0) refused (EPERM): a sandbox or another user. The HTTP proof would not help:
|
|
441
|
+
// the stop signal itself would be refused too (AI-M03).
|
|
442
|
+
else
|
|
443
|
+
reasons.push(`pid ${record.pid} cannot be signalled from this shell (a sandbox or another user): stopping needs its log writer or a shell that may signal it`);
|
|
444
|
+
throw new CliError(failure.code, failure.message, {
|
|
445
|
+
cause: failure.cause ?? failure,
|
|
446
|
+
details: {
|
|
447
|
+
...failure.details,
|
|
448
|
+
instanceProof: { tried, reason: reasons.join('; ') },
|
|
449
|
+
},
|
|
450
|
+
});
|
|
451
|
+
}
|
|
186
452
|
async function checkProject(root) {
|
|
187
453
|
const { entry } = paths(root);
|
|
188
454
|
if (!existsSync(entry))
|
|
@@ -196,10 +462,6 @@ async function checkProject(root) {
|
|
|
196
462
|
});
|
|
197
463
|
return entry;
|
|
198
464
|
}
|
|
199
|
-
const environment = (mode) => ({
|
|
200
|
-
...process.env,
|
|
201
|
-
NODE_ENV: process.env.NODE_ENV ?? mode,
|
|
202
|
-
});
|
|
203
465
|
/**
|
|
204
466
|
* The last `lines` lines of one file (reading at most its last `maxBytes`). An open handle is
|
|
205
467
|
* read up to `end` (the size it had when opened) and left open.
|
|
@@ -272,8 +534,20 @@ check = checkProject) {
|
|
|
272
534
|
details: current,
|
|
273
535
|
});
|
|
274
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
|
+
});
|
|
275
543
|
await mkdir(files.run, { recursive: true });
|
|
276
544
|
await writeFile(join(files.run, '.gitignore'), '*\n');
|
|
545
|
+
// Lines this start writes begin here (the web listening line is parsed from them).
|
|
546
|
+
const logOffset = await stat(files.log).then((info) => info.size, (error) => {
|
|
547
|
+
if (!isMissing(error))
|
|
548
|
+
throw error;
|
|
549
|
+
return 0;
|
|
550
|
+
});
|
|
277
551
|
// One start at a time; a lock left by a start that died more than a minute ago is stale.
|
|
278
552
|
try {
|
|
279
553
|
await writeFile(files.lock, String(process.pid), { flag: 'wx' });
|
|
@@ -309,7 +583,8 @@ check = checkProject) {
|
|
|
309
583
|
cwd: root,
|
|
310
584
|
detached: true,
|
|
311
585
|
stdio: ['ignore', 'ignore', writerLog.fd, 'ipc'],
|
|
312
|
-
|
|
586
|
+
// The writer starts the app with its own environment.
|
|
587
|
+
env,
|
|
313
588
|
});
|
|
314
589
|
}
|
|
315
590
|
finally {
|
|
@@ -320,6 +595,9 @@ check = checkProject) {
|
|
|
320
595
|
throw new CliError('START_FAILED', 'The log writer process could not be spawned');
|
|
321
596
|
const state = {};
|
|
322
597
|
writer.on('message', (message) => {
|
|
598
|
+
// The writer sends its control port before it spawns the app (IPC keeps the order).
|
|
599
|
+
if (typeof message?.control === 'number')
|
|
600
|
+
state.control = message.control;
|
|
323
601
|
if (typeof message?.spawned === 'number')
|
|
324
602
|
state.pid = message.spawned;
|
|
325
603
|
if (message?.ready)
|
|
@@ -342,18 +620,24 @@ check = checkProject) {
|
|
|
342
620
|
return new CliError('START_FAILED', message, {
|
|
343
621
|
details: {
|
|
344
622
|
log: files.log,
|
|
345
|
-
|
|
346
|
-
|
|
623
|
+
// App output in a result: credentials masked like npm output (logs prints it as is).
|
|
624
|
+
tail: redactNpmOutput(await tail(files.log, 30)),
|
|
625
|
+
logWriterLog: redactNpmOutput((await lastLines(files.writerLog, 30)).join('\n')),
|
|
347
626
|
},
|
|
348
627
|
});
|
|
349
628
|
};
|
|
629
|
+
// The pid file holds the instance token: only the owner reads it (0600).
|
|
630
|
+
const save = async (record) => {
|
|
631
|
+
await rm(`${files.pid}.tmp`, { force: true });
|
|
632
|
+
await writeFile(`${files.pid}.tmp`, `${JSON.stringify(record)}\n`, { mode: 0o600 });
|
|
633
|
+
await rename(`${files.pid}.tmp`, files.pid);
|
|
634
|
+
};
|
|
350
635
|
const deadline = performance.now() + waitMs;
|
|
351
636
|
let recorded;
|
|
352
637
|
while (performance.now() < deadline) {
|
|
353
638
|
await delay(50);
|
|
354
639
|
if (state.pid !== undefined && recorded === undefined) {
|
|
355
|
-
recorded =
|
|
356
|
-
const record = {
|
|
640
|
+
recorded = {
|
|
357
641
|
pid: state.pid,
|
|
358
642
|
startedAt: new Date().toISOString(),
|
|
359
643
|
entry,
|
|
@@ -361,9 +645,10 @@ check = checkProject) {
|
|
|
361
645
|
logWriter: writerPid,
|
|
362
646
|
logMaxBytes: maxBytes,
|
|
363
647
|
logFiles: keep,
|
|
648
|
+
token,
|
|
649
|
+
...(state.control !== undefined ? { control: state.control } : {}),
|
|
364
650
|
};
|
|
365
|
-
await
|
|
366
|
-
await rename(`${files.pid}.tmp`, files.pid);
|
|
651
|
+
await save(recorded);
|
|
367
652
|
debug('App process spawned; waiting for the start line', { pid: state.pid });
|
|
368
653
|
}
|
|
369
654
|
if (state.exited !== undefined)
|
|
@@ -382,13 +667,20 @@ check = checkProject) {
|
|
|
382
667
|
const ready = state.ready === true;
|
|
383
668
|
if (!ready)
|
|
384
669
|
warnings.push(`"${STARTED}" was not logged within ${waitMs} ms; the process is running but may not be ready`);
|
|
670
|
+
const listen = ready ? await startedUrls(files.log, logOffset) : [];
|
|
671
|
+
// Where stop and status ask for the instance proof when ps is unavailable (D-0073): the
|
|
672
|
+
// listening address (the proof does not depend on the Host header).
|
|
673
|
+
if (listen.length)
|
|
674
|
+
await save({ ...recorded, urls: listen });
|
|
385
675
|
return {
|
|
386
|
-
pid: recorded,
|
|
676
|
+
pid: recorded.pid,
|
|
387
677
|
log: files.log,
|
|
388
678
|
logWriter: writerPid,
|
|
389
679
|
logMaxBytes: maxBytes,
|
|
390
680
|
logFiles: keep,
|
|
391
681
|
ready,
|
|
682
|
+
urls: appUrls(listen, await appHostsOrNone(root)),
|
|
683
|
+
listen,
|
|
392
684
|
ms: Math.round(performance.now() - began),
|
|
393
685
|
warnings,
|
|
394
686
|
};
|
|
@@ -401,16 +693,115 @@ check = checkProject) {
|
|
|
401
693
|
await rm(files.lock, { force: true });
|
|
402
694
|
}
|
|
403
695
|
}
|
|
404
|
-
|
|
696
|
+
/**
|
|
697
|
+
* HTTP URLs from `Web server listening` log lines (D-0065): `address`, `addresses` or `urls`
|
|
698
|
+
* fields as strings (`http://…`) or `{ address, port }` objects. Unknown shapes are skipped.
|
|
699
|
+
*/
|
|
700
|
+
export function listeningUrls(text) {
|
|
701
|
+
const urls = new Set();
|
|
702
|
+
const add = (value) => {
|
|
703
|
+
if (typeof value === 'string' && /^https?:\/\//.test(value))
|
|
704
|
+
urls.add(value.replace(/\/$/, ''));
|
|
705
|
+
else if (value && typeof value === 'object') {
|
|
706
|
+
const { address, port } = value;
|
|
707
|
+
if (typeof address === 'string' && typeof port === 'number')
|
|
708
|
+
urls.add(`http://${address.includes(':') ? `[${address}]` : address}:${port}`);
|
|
709
|
+
}
|
|
710
|
+
};
|
|
711
|
+
for (const line of text.split('\n')) {
|
|
712
|
+
if (!/listening/i.test(line))
|
|
713
|
+
continue;
|
|
714
|
+
let record;
|
|
715
|
+
try {
|
|
716
|
+
record = JSON.parse(line);
|
|
717
|
+
}
|
|
718
|
+
catch (error) {
|
|
719
|
+
// Plain output of the app, not a JSON log line: it carries no address.
|
|
720
|
+
debug('Skipped a non-JSON log line while looking for addresses', {
|
|
721
|
+
error: error instanceof Error ? error.message : String(error),
|
|
722
|
+
});
|
|
723
|
+
continue;
|
|
724
|
+
}
|
|
725
|
+
if (typeof record.message !== 'string' || !/web server listening/i.test(record.message))
|
|
726
|
+
continue;
|
|
727
|
+
for (const key of ['address', 'addresses', 'url', 'urls']) {
|
|
728
|
+
const value = record[key];
|
|
729
|
+
for (const item of Array.isArray(value) ? value : [value])
|
|
730
|
+
add(item);
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
return [...urls];
|
|
734
|
+
}
|
|
735
|
+
/**
|
|
736
|
+
* The app host settings for the shown URLs; when they cannot be read the listening addresses are
|
|
737
|
+
* shown instead (the app already runs: this is no reason to fail the command).
|
|
738
|
+
*/
|
|
739
|
+
export async function appHostsOrNone(root) {
|
|
740
|
+
try {
|
|
741
|
+
return await readAppHosts(root);
|
|
742
|
+
}
|
|
743
|
+
catch (error) {
|
|
744
|
+
reportError('APP_HOSTS_FAILED', 'Unable to read the app domains; the listening addresses are shown instead', error);
|
|
745
|
+
return { hosted: false, domains: [], answersUnknown: false };
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
async function startedUrls(log, offset) {
|
|
749
|
+
try {
|
|
750
|
+
const bytes = await readFile(log);
|
|
751
|
+
// A rotation since the start moved the lines: read the whole current file then.
|
|
752
|
+
return listeningUrls((bytes.length >= offset ? bytes.subarray(offset) : bytes).toString('utf8'));
|
|
753
|
+
}
|
|
754
|
+
catch (error) {
|
|
755
|
+
if (!isMissing(error))
|
|
756
|
+
reportError('LOG_READ_FAILED', 'Unable to read the app log', error);
|
|
757
|
+
return [];
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
export async function stop(root, timeoutMs = 30_000, force = false,
|
|
761
|
+
/** Tests replace how a process is read; the CLI uses ps (/proc on Linux). */
|
|
762
|
+
read = readProcess,
|
|
763
|
+
/** Tests replace how a process is signalled; the CLI uses process.kill. */
|
|
764
|
+
kill = signalProcess) {
|
|
405
765
|
daemonOnly();
|
|
406
766
|
const files = paths(root);
|
|
407
767
|
const record = await readRecord(root);
|
|
408
768
|
if (!record)
|
|
409
769
|
return { stopped: false };
|
|
410
|
-
|
|
770
|
+
// Without process information the app's log writer or the app itself must prove the instance
|
|
771
|
+
// token (D-0073); otherwise PROCESS_INFO_FAILED propagates and the pid file stays (a live
|
|
772
|
+
// server is not orphaned, an unproven pid is never signalled).
|
|
773
|
+
let proof = 'process';
|
|
774
|
+
let found;
|
|
775
|
+
try {
|
|
776
|
+
found = Boolean(await live(record, read));
|
|
777
|
+
}
|
|
778
|
+
catch (error) {
|
|
779
|
+
if (!processInfoFailure(error))
|
|
780
|
+
throw error;
|
|
781
|
+
const how = await withoutProcessInfo(root, record, error, kill);
|
|
782
|
+
found = how !== 'gone';
|
|
783
|
+
if (how !== 'gone')
|
|
784
|
+
proof = how;
|
|
785
|
+
}
|
|
786
|
+
if (!found) {
|
|
787
|
+
debug('Removing a stale pid file', { pid: record.pid });
|
|
411
788
|
await rm(files.pid, { force: true });
|
|
412
789
|
return { stopped: false, pid: record.pid, stale: true };
|
|
413
790
|
}
|
|
791
|
+
if (proof === 'log-writer')
|
|
792
|
+
return stopThroughWriter(root, record, timeoutMs, force, proof);
|
|
793
|
+
// SIGTERM goes to the app (core drain); the log writer exits once the app's output ends.
|
|
794
|
+
debug('Sending SIGTERM', { pid: record.pid, timeoutMs, proof });
|
|
795
|
+
try {
|
|
796
|
+
kill(record.pid, 'SIGTERM');
|
|
797
|
+
}
|
|
798
|
+
catch (error) {
|
|
799
|
+
if (error.code !== 'EPERM')
|
|
800
|
+
throw error;
|
|
801
|
+
// The app runs in another sandbox instance (AI-M03): its log writer may signal it.
|
|
802
|
+
debug('SIGTERM refused; asking the log writer', { pid: record.pid, proof });
|
|
803
|
+
return stopThroughWriter(root, record, timeoutMs, force, proof, error);
|
|
804
|
+
}
|
|
414
805
|
const began = performance.now();
|
|
415
806
|
const gone = async (alive, limit) => {
|
|
416
807
|
const until = performance.now() + limit;
|
|
@@ -421,30 +812,160 @@ export async function stop(root, timeoutMs = 30_000, force = false) {
|
|
|
421
812
|
}
|
|
422
813
|
return false;
|
|
423
814
|
};
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
815
|
+
// Proven over HTTP: liveness is kill(pid, 0) from here on, as in stopStarted.
|
|
816
|
+
const app = proof === 'process'
|
|
817
|
+
? async () => Boolean(await live(record, read))
|
|
818
|
+
: async () => pidExists(record.pid, kill);
|
|
819
|
+
const writer = proof === 'process'
|
|
820
|
+
? () => writerAlive(record, read)
|
|
821
|
+
: async () => record.logWriter !== undefined && pidExists(record.logWriter, kill);
|
|
428
822
|
let forced = false;
|
|
429
823
|
if (!(await gone(app, timeoutMs))) {
|
|
430
824
|
if (!force)
|
|
431
825
|
throw new CliError('STOP_TIMEOUT', `pid ${record.pid} did not stop within ${timeoutMs} ms; it is still draining or stuck (use --force)`);
|
|
432
826
|
debug('Stop timed out; sending SIGKILL', { pid: record.pid });
|
|
433
|
-
|
|
827
|
+
kill(record.pid, 'SIGKILL');
|
|
434
828
|
forced = true;
|
|
435
829
|
if (!(await gone(app, 5000)))
|
|
436
830
|
throw new CliError('STOP_TIMEOUT', `pid ${record.pid} survived SIGKILL`);
|
|
437
831
|
}
|
|
438
832
|
await rm(files.pid, { force: true });
|
|
439
833
|
const warnings = [];
|
|
440
|
-
|
|
834
|
+
// The app is stopped and its pid file removed: a failure to read the writer is a warning.
|
|
835
|
+
const writerGone = await gone(writer, 5000).catch((error) => {
|
|
836
|
+
reportError('PROCESS_INFO_FAILED', 'Unable to check the log writer after stop', error);
|
|
837
|
+
warnings.push(`could not check the log writer (pid ${record.logWriter}): ${error instanceof Error ? error.message : String(error)}`);
|
|
838
|
+
return true;
|
|
839
|
+
});
|
|
840
|
+
if (record.logWriter !== undefined && !writerGone) {
|
|
841
|
+
debug('Log writer still running after the app stopped', { writer: record.logWriter });
|
|
842
|
+
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`);
|
|
843
|
+
}
|
|
844
|
+
return {
|
|
845
|
+
stopped: true,
|
|
846
|
+
pid: record.pid,
|
|
847
|
+
ms: Math.round(performance.now() - began),
|
|
848
|
+
forced,
|
|
849
|
+
proof,
|
|
850
|
+
via: 'signal',
|
|
851
|
+
...(warnings.length ? { warnings } : {}),
|
|
852
|
+
};
|
|
853
|
+
}
|
|
854
|
+
/**
|
|
855
|
+
* The stop through the app's log writer (D-0073 addendum): it sends SIGTERM, waits `timeoutMs`
|
|
856
|
+
* and, with `force`, SIGKILL. Then the pid file goes and the writer's exit is awaited (it closes
|
|
857
|
+
* the connection). An unreachable or unproven writer leaves everything as it was.
|
|
858
|
+
*/
|
|
859
|
+
async function stopThroughWriter(root, record, timeoutMs, force, proof, refused) {
|
|
860
|
+
const files = paths(root);
|
|
861
|
+
const began = performance.now();
|
|
862
|
+
const asked = await askWriter(record, { op: 'stop', timeoutMs, force });
|
|
863
|
+
if (!asked.reached)
|
|
864
|
+
throw new CliError('STOP_FAILED', `Unable to stop pid ${record.pid}: this shell may not signal it (a sandbox or another user) and its log writer could not be asked (${asked.reason})`, {
|
|
865
|
+
cause: refused,
|
|
866
|
+
details: {
|
|
867
|
+
pid: record.pid,
|
|
868
|
+
logWriter: asked.reason,
|
|
869
|
+
hint: 'run dokebi stop from a shell that may signal the app (outside the sandbox)',
|
|
870
|
+
manualStop: `kill -TERM ${record.pid} from such a shell (drain), then remove .dokebi/run/app.pid`,
|
|
871
|
+
},
|
|
872
|
+
});
|
|
873
|
+
const { reply } = asked;
|
|
874
|
+
if (reply.ok !== true) {
|
|
875
|
+
await asked.closed(0);
|
|
876
|
+
throw new CliError(reply.code === 'STOP_TIMEOUT' ? 'STOP_TIMEOUT' : 'STOP_FAILED', typeof reply.message === 'string'
|
|
877
|
+
? reply.message
|
|
878
|
+
: `The log writer could not stop pid ${record.pid} (${String(reply.code)})`);
|
|
879
|
+
}
|
|
880
|
+
await rm(files.pid, { force: true });
|
|
881
|
+
if (reply.stopped !== true) {
|
|
882
|
+
debug('The log writer reports that the app had already exited', { pid: record.pid });
|
|
883
|
+
await asked.closed(0);
|
|
884
|
+
return { stopped: false, pid: record.pid, stale: true };
|
|
885
|
+
}
|
|
886
|
+
const warnings = [];
|
|
887
|
+
// The writer closes the connection once it finished the log and exits.
|
|
888
|
+
if (!(await asked.closed(5000))) {
|
|
441
889
|
debug('Log writer still running after the app stopped', { writer: record.logWriter });
|
|
442
890
|
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
891
|
}
|
|
892
|
+
debug('The log writer stopped the app', { pid: record.pid, forced: reply.forced });
|
|
444
893
|
return {
|
|
445
894
|
stopped: true,
|
|
446
895
|
pid: record.pid,
|
|
447
896
|
ms: Math.round(performance.now() - began),
|
|
897
|
+
forced: reply.forced === true,
|
|
898
|
+
proof,
|
|
899
|
+
via: 'log-writer',
|
|
900
|
+
...(warnings.length ? { warnings } : {}),
|
|
901
|
+
};
|
|
902
|
+
}
|
|
903
|
+
/** Whether a pid exists, by `kill(pid, 0)` (no `ps`): ESRCH is gone, anything else throws. */
|
|
904
|
+
function pidExists(pid, kill = signalProcess) {
|
|
905
|
+
try {
|
|
906
|
+
kill(pid, 0);
|
|
907
|
+
return true;
|
|
908
|
+
}
|
|
909
|
+
catch (error) {
|
|
910
|
+
if (error.code === 'ESRCH')
|
|
911
|
+
return false;
|
|
912
|
+
throw new CliError('STOP_FAILED', `Unable to signal pid ${pid}`, {
|
|
913
|
+
cause: error,
|
|
914
|
+
details: { pid },
|
|
915
|
+
});
|
|
916
|
+
}
|
|
917
|
+
}
|
|
918
|
+
/**
|
|
919
|
+
* Stops the app this CLI run started (`start`'s result), without `ps` (see the file comment):
|
|
920
|
+
* SIGTERM, wait for the drain, SIGKILL after `timeoutMs` when `force`, then the pid file (when it
|
|
921
|
+
* still names this process) and a wait for the log writer. An app that already exited by itself
|
|
922
|
+
* is `{ stopped: false, stale: true }`.
|
|
923
|
+
*/
|
|
924
|
+
export async function stopStarted(root, started, timeoutMs = 30_000, force = false) {
|
|
925
|
+
daemonOnly();
|
|
926
|
+
const files = paths(root);
|
|
927
|
+
const forget = async () => {
|
|
928
|
+
// Only the pid file of this start: another start may have replaced it meanwhile.
|
|
929
|
+
if ((await readRecord(root))?.pid === started.pid)
|
|
930
|
+
await rm(files.pid, { force: true });
|
|
931
|
+
};
|
|
932
|
+
const gone = async (pid, limit) => {
|
|
933
|
+
const until = performance.now() + limit;
|
|
934
|
+
while (performance.now() < until) {
|
|
935
|
+
if (!pidExists(pid))
|
|
936
|
+
return true;
|
|
937
|
+
await delay(100);
|
|
938
|
+
}
|
|
939
|
+
return !pidExists(pid);
|
|
940
|
+
};
|
|
941
|
+
if (!pidExists(started.pid)) {
|
|
942
|
+
debug('The started app had already exited', { pid: started.pid });
|
|
943
|
+
await forget();
|
|
944
|
+
return { stopped: false, pid: started.pid, stale: true };
|
|
945
|
+
}
|
|
946
|
+
const began = performance.now();
|
|
947
|
+
debug('Sending SIGTERM to the started app', { pid: started.pid, timeoutMs });
|
|
948
|
+
process.kill(started.pid, 'SIGTERM');
|
|
949
|
+
let forced = false;
|
|
950
|
+
if (!(await gone(started.pid, timeoutMs))) {
|
|
951
|
+
if (!force)
|
|
952
|
+
throw new CliError('STOP_TIMEOUT', `pid ${started.pid} did not stop within ${timeoutMs} ms; it is still draining or stuck`);
|
|
953
|
+
debug('Stop timed out; sending SIGKILL', { pid: started.pid });
|
|
954
|
+
process.kill(started.pid, 'SIGKILL');
|
|
955
|
+
forced = true;
|
|
956
|
+
if (!(await gone(started.pid, 5000)))
|
|
957
|
+
throw new CliError('STOP_TIMEOUT', `pid ${started.pid} survived SIGKILL`);
|
|
958
|
+
}
|
|
959
|
+
await forget();
|
|
960
|
+
const warnings = [];
|
|
961
|
+
if (!(await gone(started.logWriter, 5000))) {
|
|
962
|
+
debug('Log writer still running after the app stopped', { writer: started.logWriter });
|
|
963
|
+
warnings.push(`the log writer (pid ${started.logWriter}) was still running 5 s after the app stopped; a process the app started may hold its output`);
|
|
964
|
+
}
|
|
965
|
+
return {
|
|
966
|
+
stopped: true,
|
|
967
|
+
pid: started.pid,
|
|
968
|
+
ms: Math.round(performance.now() - began),
|
|
448
969
|
forced,
|
|
449
970
|
...(warnings.length ? { warnings } : {}),
|
|
450
971
|
};
|
|
@@ -656,7 +1177,8 @@ export async function foreground(root) {
|
|
|
656
1177
|
const child = spawn(process.execPath, [entry], {
|
|
657
1178
|
cwd: root,
|
|
658
1179
|
stdio: 'inherit',
|
|
659
|
-
env
|
|
1180
|
+
// The project's .env under the CLI's environment (D-0073).
|
|
1181
|
+
env: appEnvironment(root, 'production'),
|
|
660
1182
|
});
|
|
661
1183
|
const forward = (signal) => () => child.kill(signal);
|
|
662
1184
|
const onInt = forward('SIGINT');
|
|
@@ -701,31 +1223,61 @@ const VIEW_FILE = /(^|[\\/])views[\\/].*\.(js|mjs|ts)$|\.(vue|css|scss|sass|less
|
|
|
701
1223
|
/**
|
|
702
1224
|
* Development loop: runs the app, restarts it (SIGTERM, drain, start) 300 ms after a change in
|
|
703
1225
|
* src/, dokebi.yaml or .env. With a Vite config, Inertia views come from the Vite dev server
|
|
704
|
-
*
|
|
705
|
-
*
|
|
1226
|
+
* (HMR, D-0045) when template-inertia supports it, otherwise from watch builds that restart the
|
|
1227
|
+
* app. With template-inertia 0.4.0 that Vite is one host process beside the app, shared by every
|
|
1228
|
+
* cluster worker and kept across app restarts (D-0045 addendum 3); older ones start it inside the app.
|
|
1229
|
+
* The first Ctrl+C drains everything; a second one kills.
|
|
706
1230
|
*/
|
|
707
1231
|
export async function dev(root, say) {
|
|
708
1232
|
const entry = await checkProject(root);
|
|
1233
|
+
// The project's .env (D-0073), read again before every (re)start: a change restarts the app
|
|
1234
|
+
// with the new values. A malformed file fails here and, later, fails that restart only.
|
|
1235
|
+
let fileEnv = readEnvFile(root);
|
|
709
1236
|
let app;
|
|
710
1237
|
let exiting = false;
|
|
711
1238
|
const hasVite = ['vite.config.ts', 'vite.config.mjs', 'vite.config.js'].some((name) => existsSync(join(root, name)));
|
|
712
1239
|
const hot = hasVite && inertiaDevServer(root);
|
|
713
1240
|
if (hot)
|
|
714
1241
|
say('Inertia views: Vite dev server with hot module replacement');
|
|
1242
|
+
// Where to open the app (AI-M03): its domains, not the listening IP the app logs.
|
|
1243
|
+
const configured = await configuredUrl(root);
|
|
1244
|
+
if (configured) {
|
|
1245
|
+
const urls = appUrls([configured], await appHostsOrNone(root));
|
|
1246
|
+
say(urls.length
|
|
1247
|
+
? `the app answers at ${urls.join(' ')}`
|
|
1248
|
+
: `listening on ${configured}; the app answers only the Host names of its domains (none is a loopback name such as localhost)`);
|
|
1249
|
+
}
|
|
715
1250
|
const guard = new URL('./dev-guard.js', import.meta.url).href;
|
|
716
1251
|
// stdio fd 3 is the IPC channel the guard watches.
|
|
717
|
-
const child = (args) => spawn(process.execPath, ['--import', guard, ...args], {
|
|
1252
|
+
const child = (args, extra = {}) => spawn(process.execPath, ['--import', guard, ...args], {
|
|
718
1253
|
cwd: root,
|
|
719
1254
|
stdio: ['inherit', 'inherit', 'inherit', 'ipc'],
|
|
720
1255
|
// 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
|
-
},
|
|
1256
|
+
env: appEnvironment(root, 'development', { DOKEBI_DEV: '1', ...(hot ? { DOKEBI_VITE_DEV: '1' } : {}), ...extra }, fileEnv),
|
|
726
1257
|
});
|
|
727
|
-
|
|
728
|
-
|
|
1258
|
+
// One Vite for the session (D-0045 addendum 3) when template-inertia has the host script.
|
|
1259
|
+
const hostScript = hot ? viteHostScript(root) : undefined;
|
|
1260
|
+
const viteHost = hostScript ? new ViteHostProcess(() => child([hostScript]), say) : undefined;
|
|
1261
|
+
debug('dev Vite mode', { hot, host: hostScript !== undefined });
|
|
1262
|
+
const run = async () => {
|
|
1263
|
+
try {
|
|
1264
|
+
fileEnv = readEnvFile(root);
|
|
1265
|
+
}
|
|
1266
|
+
catch (error) {
|
|
1267
|
+
if (error instanceof CliError)
|
|
1268
|
+
say(`${error.message}; fix it and save again`);
|
|
1269
|
+
// Reported by the caller (DEV_START_FAILED / DEV_RESTART_FAILED); the next change retries.
|
|
1270
|
+
throw error;
|
|
1271
|
+
}
|
|
1272
|
+
let extra = {};
|
|
1273
|
+
if (viteHost) {
|
|
1274
|
+
const address = await viteHost.ensure();
|
|
1275
|
+
// Said by the host process; the next change starts it again.
|
|
1276
|
+
if (!address || exiting)
|
|
1277
|
+
return;
|
|
1278
|
+
extra = { DOKEBI_VITE_DEV_URL: address.url, DOKEBI_VITE_DEV_KEY: address.key };
|
|
1279
|
+
}
|
|
1280
|
+
app = child([entry], extra);
|
|
729
1281
|
const current = app;
|
|
730
1282
|
current.once('exit', (code, signal) => {
|
|
731
1283
|
if (app === current && !exiting)
|
|
@@ -793,9 +1345,11 @@ export async function dev(root, say) {
|
|
|
793
1345
|
await Promise.all(helpers.map((helper) => halt(helper, 5000)));
|
|
794
1346
|
throw error;
|
|
795
1347
|
}
|
|
796
|
-
run();
|
|
797
1348
|
let timer;
|
|
798
|
-
let restarting =
|
|
1349
|
+
let restarting = run().catch((error) => {
|
|
1350
|
+
// Keep the loop alive: the next change tries again.
|
|
1351
|
+
reportError('DEV_START_FAILED', 'Starting the app failed', error);
|
|
1352
|
+
});
|
|
799
1353
|
const changed = (what) => {
|
|
800
1354
|
if (exiting)
|
|
801
1355
|
return;
|
|
@@ -810,7 +1364,7 @@ export async function dev(root, say) {
|
|
|
810
1364
|
app = undefined;
|
|
811
1365
|
await halt(previous);
|
|
812
1366
|
if (!exiting)
|
|
813
|
-
run();
|
|
1367
|
+
await run();
|
|
814
1368
|
})
|
|
815
1369
|
.catch((error) => {
|
|
816
1370
|
// Keep the loop alive: the next change tries again.
|
|
@@ -831,6 +1385,9 @@ export async function dev(root, say) {
|
|
|
831
1385
|
watch(root, (_event, name) => {
|
|
832
1386
|
if (name === 'dokebi.yaml' || name === '.env')
|
|
833
1387
|
changed(name);
|
|
1388
|
+
// A running Vite reloads its own config; a host that failed to start tries again.
|
|
1389
|
+
else if (viteHost && name && /^vite\.config\.[cm]?[jt]s$/.test(name) && !viteHost.running)
|
|
1390
|
+
changed(name);
|
|
834
1391
|
}),
|
|
835
1392
|
];
|
|
836
1393
|
return await new Promise((resolve) => {
|
|
@@ -839,6 +1396,7 @@ export async function dev(root, say) {
|
|
|
839
1396
|
app?.kill('SIGKILL');
|
|
840
1397
|
for (const helper of helpers)
|
|
841
1398
|
helper.kill('SIGKILL');
|
|
1399
|
+
await viteHost?.stop(0, true);
|
|
842
1400
|
resolve(130);
|
|
843
1401
|
return;
|
|
844
1402
|
}
|
|
@@ -849,6 +1407,8 @@ export async function dev(root, say) {
|
|
|
849
1407
|
watcher.close();
|
|
850
1408
|
await restarting;
|
|
851
1409
|
await Promise.all([halt(app), ...helpers.map((helper) => halt(helper, 5000))]);
|
|
1410
|
+
// After the app: its requests and server renders go to the host until it has drained.
|
|
1411
|
+
await viteHost?.stop();
|
|
852
1412
|
resolve(0);
|
|
853
1413
|
};
|
|
854
1414
|
process.on('SIGINT', () => void finish());
|