@dokebi/cli 0.4.2 → 0.4.27
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AI.md +279 -57
- package/README.md +35 -10
- package/catalog.json +2520 -258
- package/describe.schema.json +748 -0
- package/dist/api-app.d.ts +2 -0
- package/dist/api-app.js +35 -0
- package/dist/app-urls.js +3 -2
- package/dist/bin.js +638 -37
- package/dist/capability-guide.d.ts +30 -0
- package/dist/capability-guide.js +122 -0
- package/dist/catalog.d.ts +16 -2
- package/dist/catalog.js +19 -7
- package/dist/check/asset-rules.d.ts +6 -0
- package/dist/check/asset-rules.js +163 -0
- package/dist/check/browser-sources.d.ts +20 -0
- package/dist/check/browser-sources.js +89 -0
- package/dist/check/console-rules.d.ts +6 -0
- package/dist/check/console-rules.js +40 -0
- package/dist/check/error-rules.d.ts +21 -0
- package/dist/check/error-rules.js +212 -0
- package/dist/check/form-rules.d.ts +12 -0
- package/dist/check/form-rules.js +69 -6
- package/dist/check/formatter-rules.d.ts +2 -0
- package/dist/check/formatter-rules.js +71 -0
- package/dist/check/i18n-rules.d.ts +27 -0
- package/dist/check/i18n-rules.js +353 -0
- package/dist/check/index.d.ts +15 -1
- package/dist/check/index.js +89 -11
- package/dist/check/mail-rules.d.ts +4 -0
- package/dist/check/mail-rules.js +72 -0
- package/dist/check/module-rules.d.ts +13 -0
- package/dist/check/module-rules.js +104 -0
- package/dist/check/packages.d.ts +8 -0
- package/dist/check/packages.js +16 -0
- package/dist/check/pool-rules.d.ts +8 -0
- package/dist/check/pool-rules.js +164 -0
- package/dist/check/problems.d.ts +6 -1
- package/dist/check/problems.js +100 -2
- package/dist/check/project-rules.d.ts +23 -3
- package/dist/check/project-rules.js +265 -23
- package/dist/check/seal-rules.d.ts +6 -0
- package/dist/check/seal-rules.js +68 -0
- package/dist/check/security-rules.d.ts +10 -0
- package/dist/check/security-rules.js +450 -0
- package/dist/check/shared-state-rules.d.ts +15 -0
- package/dist/check/shared-state-rules.js +117 -0
- package/dist/check/source-rules.d.ts +26 -3
- package/dist/check/source-rules.js +249 -28
- package/dist/check/structure-rules.d.ts +23 -0
- package/dist/check/structure-rules.js +502 -0
- package/dist/check/timer-rules.d.ts +7 -0
- package/dist/check/timer-rules.js +543 -0
- package/dist/check/typescript.d.ts +35 -5
- package/dist/check/typescript.js +163 -17
- package/dist/check/vite-rules.d.ts +2 -0
- package/dist/check/vite-rules.js +136 -0
- package/dist/check/wait-rules.d.ts +16 -0
- package/dist/check/wait-rules.js +337 -0
- package/dist/commands.d.ts +7 -0
- package/dist/commands.js +381 -36
- package/dist/config-diff.d.ts +42 -0
- package/dist/config-diff.js +289 -0
- package/dist/config-edit.d.ts +35 -2
- package/dist/config-edit.js +145 -13
- package/dist/config-init.d.ts +15 -0
- package/dist/config-init.js +61 -0
- package/dist/config-paths.d.ts +22 -0
- package/dist/config-paths.js +82 -0
- package/dist/config-secrets.d.ts +46 -0
- package/dist/config-secrets.js +187 -0
- package/dist/config-show.d.ts +82 -0
- package/dist/config-show.js +563 -0
- package/dist/config-values.d.ts +8 -0
- package/dist/config-values.js +68 -0
- package/dist/configuration-scaffold.d.ts +42 -0
- package/dist/configuration-scaffold.js +258 -4
- package/dist/configuration.d.ts +63 -3
- package/dist/configuration.js +178 -4
- package/dist/deploy-scripts.d.ts +4 -0
- package/dist/deploy-scripts.js +361 -0
- package/dist/deploy-templates.d.ts +46 -0
- package/dist/deploy-templates.js +379 -0
- package/dist/deploy.d.ts +46 -0
- package/dist/deploy.js +592 -0
- package/dist/deployment.d.ts +2 -0
- package/dist/deployment.js +111 -0
- package/dist/describe-db.d.ts +22 -0
- package/dist/describe-db.js +176 -0
- package/dist/describe.d.ts +92 -0
- package/dist/describe.js +417 -0
- package/dist/dev-compose.d.ts +20 -0
- package/dist/dev-compose.js +154 -0
- package/dist/dev-guard.js +44 -0
- package/dist/dev-host.d.ts +13 -0
- package/dist/dev-host.js +55 -0
- package/dist/dev-infra.d.ts +72 -4
- package/dist/dev-infra.js +237 -17
- package/dist/dev-watch.d.ts +34 -0
- package/dist/dev-watch.js +192 -0
- package/dist/diagnostics.d.ts +10 -7
- package/dist/diagnostics.js +22 -4
- package/dist/drift.d.ts +50 -0
- package/dist/drift.js +218 -0
- package/dist/env-file.d.ts +25 -1
- package/dist/env-file.js +64 -6
- package/dist/errors.d.ts +7 -1
- package/dist/example-config.d.ts +2 -0
- package/dist/example-config.js +3 -0
- package/dist/first-admin.d.ts +43 -0
- package/dist/first-admin.js +175 -0
- package/dist/formatter-ignore.d.ts +37 -0
- package/dist/formatter-ignore.js +344 -0
- package/dist/generator.d.ts +45 -3
- package/dist/generator.js +205 -15
- package/dist/guide.d.ts +1 -1
- package/dist/guide.js +42 -21
- package/dist/install-module.d.ts +20 -2
- package/dist/install-module.js +45 -8
- package/dist/install-plan.js +5 -3
- package/dist/layout.d.ts +12 -1
- package/dist/layout.js +19 -6
- package/dist/log-writer-control.d.ts +2 -0
- package/dist/log-writer-control.js +17 -2
- package/dist/log-writer.js +43 -10
- package/dist/logging-config.d.ts +8 -3
- package/dist/logging-config.js +19 -5
- package/dist/manifest.d.ts +29 -1
- package/dist/manifest.js +84 -6
- package/dist/merge-install-defaults.d.ts +0 -1
- package/dist/merge-install-defaults.js +13 -47
- package/dist/npm-client.d.ts +40 -4
- package/dist/npm-client.js +152 -15
- package/dist/npm-paths.js +18 -4
- package/dist/openapi.d.ts +27 -0
- package/dist/openapi.js +649 -0
- package/dist/option-fixes.d.ts +39 -0
- package/dist/option-fixes.js +130 -0
- package/dist/output.d.ts +1 -1
- package/dist/output.js +5 -5
- package/dist/plugin-lifecycle.d.ts +34 -2
- package/dist/plugin-lifecycle.js +195 -16
- package/dist/plugin-options.d.ts +19 -4
- package/dist/plugin-options.js +138 -6
- package/dist/plugins-command.js +44 -7
- package/dist/process-group.d.ts +39 -0
- package/dist/process-group.js +132 -0
- package/dist/production-doctor.d.ts +3 -0
- package/dist/production-doctor.js +176 -0
- package/dist/project-guide.d.ts +19 -1
- package/dist/project-guide.js +119 -20
- package/dist/project-inspector.d.ts +15 -0
- package/dist/project-inspector.js +497 -33
- package/dist/project-state.d.ts +5 -0
- package/dist/project-state.js +8 -0
- package/dist/project.d.ts +203 -19
- package/dist/project.js +3006 -333
- package/dist/recipe-facts.d.ts +127 -0
- package/dist/recipe-facts.js +563 -0
- package/dist/recipe-login.d.ts +63 -0
- package/dist/recipe-login.js +464 -0
- package/dist/recipe-mail.d.ts +21 -0
- package/dist/recipe-mail.js +214 -0
- package/dist/recipe-members.d.ts +35 -0
- package/dist/recipe-members.js +178 -0
- package/dist/recipe-record.d.ts +82 -7
- package/dist/recipe-record.js +226 -22
- package/dist/recipe-reset.d.ts +2 -0
- package/dist/recipe-reset.js +117 -0
- package/dist/recipe-site.d.ts +48 -0
- package/dist/recipe-site.js +396 -0
- package/dist/recipe-step.d.ts +42 -0
- package/dist/recipe-step.js +89 -0
- package/dist/recipe.d.ts +56 -20
- package/dist/recipe.js +703 -131
- package/dist/registry.d.ts +98 -2
- package/dist/registry.js +171 -5
- package/dist/reloadable.d.ts +35 -0
- package/dist/reloadable.js +257 -0
- package/dist/remote-verify.d.ts +31 -0
- package/dist/remote-verify.js +204 -0
- package/dist/server.d.ts +43 -4
- package/dist/server.js +601 -52
- package/dist/settings.d.ts +48 -0
- package/dist/settings.js +168 -0
- package/dist/site-auth.d.ts +39 -0
- package/dist/site-auth.js +195 -0
- package/dist/template-fixes.d.ts +26 -0
- package/dist/template-fixes.js +285 -0
- package/dist/test-reporter.d.ts +49 -0
- package/dist/test-reporter.js +190 -0
- package/dist/transaction.d.ts +47 -0
- package/dist/transaction.js +241 -7
- package/dist/verify-tests.d.ts +98 -0
- package/dist/verify-tests.js +449 -0
- package/dist/verify.d.ts +103 -3
- package/dist/verify.js +272 -21
- package/dist/view-choice.d.ts +58 -0
- package/dist/view-choice.js +131 -0
- package/dist/vite-watch.js +4 -2
- package/package.json +8 -6
package/dist/server.js
CHANGED
|
@@ -30,17 +30,19 @@ import { existsSync, readFileSync, watch } from 'node:fs';
|
|
|
30
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
|
});
|
|
@@ -434,15 +438,18 @@ function pidState(pid, kill) {
|
|
|
434
438
|
return 'unknown';
|
|
435
439
|
}
|
|
436
440
|
}
|
|
437
|
-
/**
|
|
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
|
+
*/
|
|
438
445
|
async function configuredUrl(root) {
|
|
439
446
|
let source;
|
|
440
447
|
try {
|
|
441
|
-
source = await
|
|
448
|
+
source = (await readProjectSettings(root)).source;
|
|
442
449
|
}
|
|
443
450
|
catch (error) {
|
|
444
451
|
if (!isMissing(error))
|
|
445
|
-
reportError('CONFIG_READ_FAILED',
|
|
452
|
+
reportError('CONFIG_READ_FAILED', 'Unable to read the settings file of the project', error);
|
|
446
453
|
return undefined;
|
|
447
454
|
}
|
|
448
455
|
let web;
|
|
@@ -603,7 +610,7 @@ async function withoutProcessInfo(root, record, failure, kill) {
|
|
|
603
610
|
},
|
|
604
611
|
});
|
|
605
612
|
}
|
|
606
|
-
async function checkProject(root) {
|
|
613
|
+
export async function checkProject(root) {
|
|
607
614
|
const { entry } = paths(root);
|
|
608
615
|
if (!existsSync(entry))
|
|
609
616
|
throw new CliError('ENTRY_MISSING', `${ENTRY} does not exist`);
|
|
@@ -664,6 +671,42 @@ async function tail(file, lines, current) {
|
|
|
664
671
|
}
|
|
665
672
|
return result.length ? `${result.join('\n')}\n` : '';
|
|
666
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
|
+
}
|
|
667
710
|
/** Rejects rotation settings outside LOG_LIMITS; bytes below 1 MB are allowed for tests. */
|
|
668
711
|
function logSettings(options) {
|
|
669
712
|
const maxBytes = options.logMaxBytes ?? LOG_LIMITS.maxMb.default * MB;
|
|
@@ -689,8 +732,26 @@ check = checkProject) {
|
|
|
689
732
|
const lock = await StartLock.acquire(files.lock);
|
|
690
733
|
if (!lock)
|
|
691
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;
|
|
692
749
|
const warnings = [];
|
|
693
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;
|
|
694
755
|
try {
|
|
695
756
|
const current = await statusOf(root, readProcess, signalProcess, true);
|
|
696
757
|
if (current.running)
|
|
@@ -698,12 +759,19 @@ check = checkProject) {
|
|
|
698
759
|
details: current,
|
|
699
760
|
});
|
|
700
761
|
const entry = await check(root);
|
|
762
|
+
interrupted();
|
|
701
763
|
// The project's .env under the CLI's environment (D-0073); a malformed file stops here.
|
|
702
764
|
const token = randomBytes(24).toString('base64url');
|
|
703
765
|
const env = appEnvironment(root, 'production', {
|
|
704
766
|
...options.env,
|
|
705
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,
|
|
706
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}`);
|
|
707
775
|
// Lines this start writes begin here (the web listening line is parsed from them).
|
|
708
776
|
const logOffset = await stat(files.log).then((info) => info.size, (error) => {
|
|
709
777
|
if (!isMissing(error))
|
|
@@ -733,13 +801,16 @@ check = checkProject) {
|
|
|
733
801
|
const writerPid = writer.pid;
|
|
734
802
|
if (writerPid === undefined)
|
|
735
803
|
throw new CliError('START_FAILED', 'The log writer process could not be spawned');
|
|
804
|
+
leftover = writerPid;
|
|
736
805
|
const state = {};
|
|
737
806
|
writer.on('message', (message) => {
|
|
738
807
|
// The writer sends its control port before it spawns the app (IPC keeps the order).
|
|
739
808
|
if (typeof message?.control === 'number')
|
|
740
809
|
state.control = message.control;
|
|
741
|
-
if (typeof message?.spawned === 'number')
|
|
810
|
+
if (typeof message?.spawned === 'number') {
|
|
742
811
|
state.pid = message.spawned;
|
|
812
|
+
appGroup = message.spawned;
|
|
813
|
+
}
|
|
743
814
|
if (message?.ready)
|
|
744
815
|
state.ready = true;
|
|
745
816
|
if (message && 'exited' in message)
|
|
@@ -757,11 +828,15 @@ check = checkProject) {
|
|
|
757
828
|
debug('Log writer spawned; waiting for the app', { writer: writerPid, waitMs, maxBytes, keep });
|
|
758
829
|
const failure = async (message) => {
|
|
759
830
|
await rm(files.pid, { force: true });
|
|
760
|
-
|
|
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, {
|
|
761
836
|
details: {
|
|
762
837
|
log: files.log,
|
|
763
838
|
// App output in a result: credentials masked like npm output (logs prints it as is).
|
|
764
|
-
tail: redactNpmOutput(
|
|
839
|
+
tail: redactNpmOutput(recent.slice(Math.max(mark + 1, recent.length - 31)).join('\n')),
|
|
765
840
|
logWriterLog: redactNpmOutput((await lastLines(files.writerLog, 30)).join('\n')),
|
|
766
841
|
},
|
|
767
842
|
});
|
|
@@ -771,11 +846,13 @@ check = checkProject) {
|
|
|
771
846
|
await rm(`${files.pid}.tmp`, { force: true });
|
|
772
847
|
await writeFile(`${files.pid}.tmp`, `${JSON.stringify(record)}\n`, { mode: 0o600 });
|
|
773
848
|
await rename(`${files.pid}.tmp`, files.pid);
|
|
849
|
+
wrote = true;
|
|
774
850
|
};
|
|
775
851
|
const deadline = performance.now() + waitMs;
|
|
776
852
|
let recorded;
|
|
777
853
|
while (performance.now() < deadline) {
|
|
778
854
|
await delay(50);
|
|
855
|
+
interrupted();
|
|
779
856
|
if (state.pid !== undefined && recorded === undefined) {
|
|
780
857
|
recorded = {
|
|
781
858
|
pid: state.pid,
|
|
@@ -791,27 +868,29 @@ check = checkProject) {
|
|
|
791
868
|
await save(recorded);
|
|
792
869
|
debug('App process spawned; waiting for the start line', { pid: state.pid });
|
|
793
870
|
}
|
|
794
|
-
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;
|
|
795
874
|
throw await failure(`The application exited with ${state.exited}`);
|
|
875
|
+
}
|
|
796
876
|
if (state.failed !== undefined)
|
|
797
877
|
throw await failure(`The application could not be started: ${state.failed}`);
|
|
798
878
|
if (state.ready && recorded !== undefined)
|
|
799
879
|
break;
|
|
800
880
|
}
|
|
801
|
-
if (recorded === undefined)
|
|
802
|
-
// The writer never started the app: do not leave it (or a late app) behind. The writer
|
|
803
|
-
// leads its own process group (detached), so the group signal reaches both.
|
|
804
|
-
process.kill(-writerPid, 'SIGKILL');
|
|
881
|
+
if (recorded === undefined)
|
|
805
882
|
throw await failure(`The log writer did not start the application within ${waitMs} ms`);
|
|
806
|
-
}
|
|
807
883
|
const ready = state.ready === true;
|
|
808
884
|
if (!ready)
|
|
809
|
-
warnings.push(`"${STARTED}" was not logged within ${waitMs} ms; the process is running but may not be ready`);
|
|
810
|
-
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) : [];
|
|
811
887
|
// Where stop and status ask for the instance proof when ps is unavailable (D-0073): the
|
|
812
888
|
// listening address (the proof does not depend on the Host header).
|
|
813
889
|
if (listen.length)
|
|
814
890
|
await save({ ...recorded, urls: listen });
|
|
891
|
+
const urls = appUrls(listen, await appHostsOrNone(root));
|
|
892
|
+
interrupted();
|
|
893
|
+
returned = true;
|
|
815
894
|
return {
|
|
816
895
|
pid: recorded.pid,
|
|
817
896
|
log: files.log,
|
|
@@ -819,18 +898,45 @@ check = checkProject) {
|
|
|
819
898
|
logMaxBytes: maxBytes,
|
|
820
899
|
logFiles: keep,
|
|
821
900
|
ready,
|
|
822
|
-
urls
|
|
901
|
+
urls,
|
|
823
902
|
listen,
|
|
824
903
|
ms: Math.round(performance.now() - began),
|
|
825
904
|
warnings,
|
|
826
905
|
};
|
|
827
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
|
+
}
|
|
828
924
|
finally {
|
|
829
925
|
// The writer and the app outlive this command.
|
|
830
926
|
if (writer?.connected)
|
|
831
927
|
writer.disconnect();
|
|
832
928
|
writer?.unref();
|
|
833
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();
|
|
834
940
|
}
|
|
835
941
|
}
|
|
836
942
|
/**
|
|
@@ -885,6 +991,38 @@ export async function appHostsOrNone(root) {
|
|
|
885
991
|
return { hosted: false, domains: [], answersUnknown: false };
|
|
886
992
|
}
|
|
887
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
|
+
}
|
|
888
1026
|
async function startedUrls(log, offset) {
|
|
889
1027
|
try {
|
|
890
1028
|
const bytes = await readFile(log);
|
|
@@ -1115,7 +1253,9 @@ export async function stopStarted(root, started, timeoutMs = 30_000, force = fal
|
|
|
1115
1253
|
* it finishes its file, then reads each rotated file newer than it (found by inode, oldest
|
|
1116
1254
|
* first) before following the new app.log, so several rotations between two polls skip no line.
|
|
1117
1255
|
*/
|
|
1118
|
-
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) {
|
|
1119
1259
|
const file = paths(root).log;
|
|
1120
1260
|
if (!follow) {
|
|
1121
1261
|
if (!existsSync(file))
|
|
@@ -1332,7 +1472,7 @@ export async function foreground(root) {
|
|
|
1332
1472
|
}
|
|
1333
1473
|
}
|
|
1334
1474
|
/** The installed version of a package, looked up like Node does (node_modules up the tree). */
|
|
1335
|
-
function installedVersion(root, name) {
|
|
1475
|
+
export function installedVersion(root, name) {
|
|
1336
1476
|
for (let directory = root;; directory = dirname(directory)) {
|
|
1337
1477
|
try {
|
|
1338
1478
|
const { version } = JSON.parse(readFileSync(join(directory, 'node_modules', name, 'package.json'), 'utf8'));
|
|
@@ -1358,16 +1498,87 @@ function inertiaDevServer(root) {
|
|
|
1358
1498
|
}
|
|
1359
1499
|
/** Files the Vite dev server owns: a change reaches the browser by HMR, not by a restart. */
|
|
1360
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
|
+
}
|
|
1361
1561
|
/**
|
|
1362
1562
|
* Development loop: runs the app, restarts it (SIGTERM, drain, start) 300 ms after a change in
|
|
1363
|
-
* src/, dokebi.yaml or .env
|
|
1364
|
-
*
|
|
1365
|
-
*
|
|
1366
|
-
*
|
|
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.
|
|
1367
1571
|
* The first Ctrl+C drains everything; a second one kills.
|
|
1368
1572
|
*/
|
|
1369
1573
|
export async function dev(root, say) {
|
|
1370
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;
|
|
1371
1582
|
// The project's .env (D-0073), read again before every (re)start: a change restarts the app
|
|
1372
1583
|
// with the new values. A malformed file fails here and, later, fails that restart only.
|
|
1373
1584
|
let fileEnv = readEnvFile(root);
|
|
@@ -1386,10 +1597,14 @@ export async function dev(root, say) {
|
|
|
1386
1597
|
: `listening on ${configured}; the app answers only the Host names of its domains (none is a loopback name such as localhost)`);
|
|
1387
1598
|
}
|
|
1388
1599
|
const guard = new URL('./dev-guard.js', import.meta.url).href;
|
|
1389
|
-
// stdio fd 3 is the IPC channel the guard watches.
|
|
1390
|
-
|
|
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], {
|
|
1391
1605
|
cwd: root,
|
|
1392
1606
|
stdio: ['inherit', 'inherit', 'inherit', 'ipc'],
|
|
1607
|
+
detached: group && PROCESS_GROUPS,
|
|
1393
1608
|
// DOKEBI_DEV: a cluster app runs one worker (@dokebi/cluster, DOKEBI_DEV_WORKERS for more).
|
|
1394
1609
|
env: appEnvironment(root, 'development', { DOKEBI_DEV: '1', ...(hot ? { DOKEBI_VITE_DEV: '1' } : {}), ...extra }, fileEnv),
|
|
1395
1610
|
});
|
|
@@ -1415,24 +1630,52 @@ export async function dev(root, say) {
|
|
|
1415
1630
|
return;
|
|
1416
1631
|
extra = { DOKEBI_VITE_DEV_URL: address.url, DOKEBI_VITE_DEV_KEY: address.key };
|
|
1417
1632
|
}
|
|
1418
|
-
app = child([entry], extra);
|
|
1633
|
+
app = child([entry], extra, true);
|
|
1419
1634
|
const current = app;
|
|
1420
1635
|
current.once('exit', (code, signal) => {
|
|
1421
1636
|
if (app === current && !exiting)
|
|
1422
1637
|
say(`app exited (${signal ?? code}); waiting for a change to restart`);
|
|
1638
|
+
if (current.pid !== undefined)
|
|
1639
|
+
void leftovers(current.pid);
|
|
1423
1640
|
});
|
|
1424
1641
|
};
|
|
1425
|
-
|
|
1426
|
-
|
|
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)
|
|
1427
1663
|
return;
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
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);
|
|
1433
1676
|
};
|
|
1434
|
-
// Vite builds in watch mode beside the app. The app starts after every first build (
|
|
1435
|
-
//
|
|
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.
|
|
1436
1679
|
const helpers = [];
|
|
1437
1680
|
const firstBuilds = [];
|
|
1438
1681
|
let built = () => { };
|
|
@@ -1484,23 +1727,52 @@ export async function dev(root, say) {
|
|
|
1484
1727
|
throw error;
|
|
1485
1728
|
}
|
|
1486
1729
|
let timer;
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
}
|
|
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 };
|
|
1491
1734
|
const changed = (what) => {
|
|
1492
1735
|
if (exiting)
|
|
1493
1736
|
return;
|
|
1737
|
+
if (what === settingsName)
|
|
1738
|
+
pending.config = true;
|
|
1739
|
+
else
|
|
1740
|
+
pending.other ??= what;
|
|
1494
1741
|
clearTimeout(timer);
|
|
1495
1742
|
timer = setTimeout(() => {
|
|
1743
|
+
const batch = pending;
|
|
1744
|
+
pending = { config: false, other: undefined };
|
|
1496
1745
|
restarting = restarting
|
|
1497
1746
|
.then(async () => {
|
|
1498
1747
|
if (exiting)
|
|
1499
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;
|
|
1500
1772
|
say(`${what} changed; restarting`);
|
|
1501
1773
|
const previous = app;
|
|
1502
1774
|
app = undefined;
|
|
1503
|
-
await halt(previous);
|
|
1775
|
+
await halt(previous, undefined, true);
|
|
1504
1776
|
if (!exiting)
|
|
1505
1777
|
await run();
|
|
1506
1778
|
})
|
|
@@ -1511,27 +1783,62 @@ export async function dev(root, say) {
|
|
|
1511
1783
|
}, 300);
|
|
1512
1784
|
};
|
|
1513
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)) });
|
|
1514
1794
|
const watchers = [
|
|
1515
|
-
watch(
|
|
1516
|
-
|
|
1517
|
-
if (hot && name && VIEW_FILE.test(name))
|
|
1795
|
+
watch(source, { recursive: true }, (_event, name) => {
|
|
1796
|
+
if (name && viewFile(name))
|
|
1518
1797
|
return;
|
|
1519
|
-
if (
|
|
1798
|
+
if (name && !watched.changedIn(source, name))
|
|
1520
1799
|
return;
|
|
1521
1800
|
changed(`src/${name ?? ''}`);
|
|
1522
1801
|
}),
|
|
1523
1802
|
watch(root, (_event, name) => {
|
|
1524
|
-
if (name
|
|
1525
|
-
|
|
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
|
+
}
|
|
1526
1809
|
// A running Vite reloads its own config; a host that failed to start tries again.
|
|
1527
|
-
else if (viteHost &&
|
|
1528
|
-
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
|
+
}
|
|
1529
1814
|
}),
|
|
1530
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
|
+
});
|
|
1531
1834
|
return await new Promise((resolve) => {
|
|
1532
1835
|
const finish = async () => {
|
|
1533
1836
|
if (exiting) {
|
|
1534
|
-
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
|
+
}
|
|
1535
1842
|
for (const helper of helpers)
|
|
1536
1843
|
helper.kill('SIGKILL');
|
|
1537
1844
|
await viteHost?.stop(0, true);
|
|
@@ -1544,7 +1851,10 @@ export async function dev(root, say) {
|
|
|
1544
1851
|
for (const watcher of watchers)
|
|
1545
1852
|
watcher.close();
|
|
1546
1853
|
await restarting;
|
|
1547
|
-
await Promise.all([
|
|
1854
|
+
await Promise.all([
|
|
1855
|
+
halt(app, undefined, true),
|
|
1856
|
+
...helpers.map((helper) => halt(helper, 5000)),
|
|
1857
|
+
]);
|
|
1548
1858
|
// After the app: its requests and server renders go to the host until it has drained.
|
|
1549
1859
|
await viteHost?.stop();
|
|
1550
1860
|
resolve(0);
|
|
@@ -1553,3 +1863,242 @@ export async function dev(root, say) {
|
|
|
1553
1863
|
process.on('SIGTERM', () => void finish());
|
|
1554
1864
|
});
|
|
1555
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
|
+
}
|