@dokebi/cli 0.4.2 → 0.4.26
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AI.md +262 -57
- package/README.md +35 -10
- package/catalog.json +2519 -257
- package/describe.schema.json +748 -0
- package/dist/api-app.d.ts +2 -0
- package/dist/api-app.js +35 -0
- package/dist/app-urls.js +3 -2
- package/dist/bin.js +518 -35
- package/dist/capability-guide.d.ts +30 -0
- package/dist/capability-guide.js +122 -0
- package/dist/catalog.d.ts +16 -2
- package/dist/catalog.js +19 -7
- package/dist/check/asset-rules.d.ts +6 -0
- package/dist/check/asset-rules.js +163 -0
- package/dist/check/browser-sources.d.ts +20 -0
- package/dist/check/browser-sources.js +89 -0
- package/dist/check/console-rules.d.ts +6 -0
- package/dist/check/console-rules.js +40 -0
- package/dist/check/error-rules.d.ts +21 -0
- package/dist/check/error-rules.js +212 -0
- package/dist/check/form-rules.d.ts +12 -0
- package/dist/check/form-rules.js +69 -6
- package/dist/check/formatter-rules.d.ts +2 -0
- package/dist/check/formatter-rules.js +71 -0
- package/dist/check/i18n-rules.d.ts +27 -0
- package/dist/check/i18n-rules.js +353 -0
- package/dist/check/index.d.ts +15 -1
- package/dist/check/index.js +89 -11
- package/dist/check/mail-rules.d.ts +4 -0
- package/dist/check/mail-rules.js +72 -0
- package/dist/check/module-rules.d.ts +13 -0
- package/dist/check/module-rules.js +104 -0
- package/dist/check/packages.d.ts +8 -0
- package/dist/check/packages.js +16 -0
- package/dist/check/pool-rules.d.ts +8 -0
- package/dist/check/pool-rules.js +164 -0
- package/dist/check/problems.d.ts +6 -1
- package/dist/check/problems.js +100 -2
- package/dist/check/project-rules.d.ts +23 -3
- package/dist/check/project-rules.js +265 -23
- package/dist/check/seal-rules.d.ts +6 -0
- package/dist/check/seal-rules.js +68 -0
- package/dist/check/security-rules.d.ts +10 -0
- package/dist/check/security-rules.js +450 -0
- package/dist/check/shared-state-rules.d.ts +2 -0
- package/dist/check/shared-state-rules.js +114 -0
- package/dist/check/source-rules.d.ts +26 -3
- package/dist/check/source-rules.js +249 -28
- package/dist/check/structure-rules.d.ts +23 -0
- package/dist/check/structure-rules.js +502 -0
- package/dist/check/timer-rules.d.ts +7 -0
- package/dist/check/timer-rules.js +543 -0
- package/dist/check/typescript.d.ts +35 -5
- package/dist/check/typescript.js +163 -17
- package/dist/check/vite-rules.d.ts +2 -0
- package/dist/check/vite-rules.js +136 -0
- package/dist/check/wait-rules.d.ts +16 -0
- package/dist/check/wait-rules.js +337 -0
- package/dist/commands.d.ts +7 -0
- package/dist/commands.js +307 -31
- package/dist/config-diff.d.ts +42 -0
- package/dist/config-diff.js +289 -0
- package/dist/config-edit.d.ts +35 -2
- package/dist/config-edit.js +145 -13
- package/dist/config-init.d.ts +15 -0
- package/dist/config-init.js +61 -0
- package/dist/config-paths.d.ts +22 -0
- package/dist/config-paths.js +82 -0
- package/dist/config-show.d.ts +82 -0
- package/dist/config-show.js +563 -0
- package/dist/config-values.d.ts +8 -0
- package/dist/config-values.js +68 -0
- package/dist/configuration-scaffold.d.ts +42 -0
- package/dist/configuration-scaffold.js +258 -4
- package/dist/configuration.d.ts +63 -3
- package/dist/configuration.js +178 -4
- package/dist/deployment.d.ts +2 -0
- package/dist/deployment.js +111 -0
- package/dist/describe-db.d.ts +22 -0
- package/dist/describe-db.js +176 -0
- package/dist/describe.d.ts +92 -0
- package/dist/describe.js +417 -0
- package/dist/dev-compose.d.ts +20 -0
- package/dist/dev-compose.js +154 -0
- package/dist/dev-guard.js +44 -0
- package/dist/dev-host.d.ts +13 -0
- package/dist/dev-host.js +55 -0
- package/dist/dev-infra.d.ts +72 -4
- package/dist/dev-infra.js +237 -17
- package/dist/dev-watch.d.ts +34 -0
- package/dist/dev-watch.js +192 -0
- package/dist/diagnostics.d.ts +10 -7
- package/dist/diagnostics.js +22 -4
- package/dist/drift.d.ts +50 -0
- package/dist/drift.js +218 -0
- package/dist/env-file.d.ts +25 -1
- package/dist/env-file.js +64 -6
- package/dist/errors.d.ts +7 -1
- package/dist/example-config.d.ts +2 -0
- package/dist/example-config.js +3 -0
- package/dist/first-admin.d.ts +43 -0
- package/dist/first-admin.js +175 -0
- package/dist/formatter-ignore.d.ts +37 -0
- package/dist/formatter-ignore.js +344 -0
- package/dist/generator.d.ts +45 -3
- package/dist/generator.js +205 -15
- package/dist/guide.d.ts +1 -1
- package/dist/guide.js +42 -21
- package/dist/install-module.d.ts +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/project-guide.d.ts +19 -1
- package/dist/project-guide.js +119 -20
- package/dist/project-inspector.d.ts +15 -0
- package/dist/project-inspector.js +481 -33
- package/dist/project-state.d.ts +5 -0
- package/dist/project-state.js +8 -0
- package/dist/project.d.ts +203 -19
- package/dist/project.js +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/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/generator.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { hostOf } from './manifest.js';
|
|
1
|
+
import { findHost, hostOf } from './manifest.js';
|
|
2
2
|
import { CLI_VERSION } from './output.js';
|
|
3
3
|
/** Import specifier of a user entry file, relative to src/dokebi.generated.ts. */
|
|
4
4
|
function entrySpecifier(target) {
|
|
@@ -16,45 +16,64 @@ function entrySpecifier(target) {
|
|
|
16
16
|
*
|
|
17
17
|
* The CLI-managed units reach the plugin whose manifest declares their role (D-0070
|
|
18
18
|
* `dokebi.hosts`): `apps` (`dokebi app add`, src/apps/<name>/app.ts) and `modules`
|
|
19
|
-
* (`dokebi module add`, src/shared/modules/<name>/module.ts).
|
|
19
|
+
* (`dokebi module add`, src/shared/modules/<name>/module.ts). The `logger` host (D-0151) gives
|
|
20
|
+
* the exported `createLogger` (src/main.ts passes its logger to the Application, so it writes
|
|
21
|
+
* from the first setup record), receives that logger as `entry.logger` and its options from
|
|
22
|
+
* `options.logging`; without one, `createLogger` returns undefined (core's console logger).
|
|
20
23
|
*/
|
|
21
|
-
export function generateRegistration(plugins, installations = {}, apps = [], modules = []) {
|
|
24
|
+
export function generateRegistration(plugins, installations = {}, apps = [], modules = [], settings = []) {
|
|
22
25
|
for (const [role, units] of [
|
|
23
26
|
['apps', apps],
|
|
24
27
|
['modules', modules],
|
|
25
28
|
])
|
|
26
29
|
if (units.length)
|
|
27
30
|
hostOf(plugins, role);
|
|
31
|
+
const logger = findHost(plugins, 'logger');
|
|
28
32
|
const imports = [];
|
|
29
33
|
const factories = [];
|
|
30
34
|
/** Plugins the CLI passes an entry (an entry file or the units they host). */
|
|
31
35
|
const withEntry = new Set();
|
|
32
36
|
plugins.forEach((plugin, index) => {
|
|
33
|
-
imports.push(`import { createPlugin as factory${index} } from ${JSON.stringify(plugin.packageName)};`);
|
|
34
37
|
const entry = installations[plugin.id]?.entry;
|
|
38
|
+
if (plugin === logger) {
|
|
39
|
+
imports.push(`import { createLogger as loggerFactory, createPlugin as factory${index} } from ${JSON.stringify(plugin.packageName)};`);
|
|
40
|
+
factories.push(` factory${index}({ app: app.view, options: optionsOf('logging'), entry: appLogger === undefined ? undefined : { logger: appLogger } }),`);
|
|
41
|
+
withEntry.add(plugin.id);
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
imports.push(`import { createPlugin as factory${index} } from ${JSON.stringify(plugin.packageName)};`);
|
|
35
45
|
if (entry) {
|
|
36
46
|
imports.push(`import * as entry${index} from ${JSON.stringify(entrySpecifier(entry))};`);
|
|
37
47
|
withEntry.add(plugin.id);
|
|
38
48
|
}
|
|
39
|
-
|
|
49
|
+
// The units of the roles the plugin hosts, together in one entry: a plugin hosting apps and
|
|
50
|
+
// modules (or apps with an entry file of its own) used to get only the last of them.
|
|
51
|
+
const units = [];
|
|
40
52
|
// The apps host receives the apps recorded by `dokebi app add` (src/apps/<name>/app.ts).
|
|
41
53
|
if (plugin.hosts?.includes('apps')) {
|
|
42
54
|
apps.forEach((name, appIndex) => {
|
|
43
55
|
imports.push(`import app${appIndex} from ${JSON.stringify(`./apps/${name}/app.ts`)};`);
|
|
44
56
|
});
|
|
45
|
-
|
|
46
|
-
withEntry.add(plugin.id);
|
|
57
|
+
units.push(`apps: { ${apps.map((name, appIndex) => `${JSON.stringify(name)}: app${appIndex}`).join(', ')} }`);
|
|
47
58
|
}
|
|
48
59
|
// The modules host receives the modules recorded by `dokebi module add` (D-0032), after the
|
|
49
60
|
// list of an older entry file (src/shared/modules/index.ts) when the project still has one.
|
|
50
|
-
|
|
61
|
+
const hostsModules = plugin.hosts?.includes('modules') === true && modules.length > 0;
|
|
62
|
+
if (hostsModules) {
|
|
51
63
|
modules.forEach((name, moduleIndex) => {
|
|
52
64
|
imports.push(`import module${moduleIndex} from ${JSON.stringify(`./shared/modules/${name}/module.ts`)};`);
|
|
53
65
|
});
|
|
54
66
|
const list = modules.map((_name, moduleIndex) => `module${moduleIndex}`);
|
|
55
|
-
|
|
56
|
-
withEntry.add(plugin.id);
|
|
67
|
+
units.push(`modules: [${[...(entry ? [`...entry${index}.modules`] : []), ...list].join(', ')}]`);
|
|
57
68
|
}
|
|
69
|
+
const own = entry && !hostsModules ? `...entry${index}, ` : '';
|
|
70
|
+
const entryCode = units.length
|
|
71
|
+
? `, entry: { ${own}${units.join(', ')} }`
|
|
72
|
+
: entry
|
|
73
|
+
? `, entry: entry${index}`
|
|
74
|
+
: '';
|
|
75
|
+
if (units.length)
|
|
76
|
+
withEntry.add(plugin.id);
|
|
58
77
|
factories.push(` factory${index}({ app: app.view, options: optionsOf(${JSON.stringify(plugin.id)})${entryCode} }),`);
|
|
59
78
|
});
|
|
60
79
|
// Plugins with an entry may receive user-chosen dependencies (for example web requires),
|
|
@@ -65,8 +84,35 @@ export function generateRegistration(plugins, installations = {}, apps = [], mod
|
|
|
65
84
|
dependencies: plugin.dependencies.map((dependency) => dependency.name),
|
|
66
85
|
entry: withEntry.has(plugin.id),
|
|
67
86
|
}));
|
|
87
|
+
const loggerCode = logger
|
|
88
|
+
? `let appLogger: ReturnType<typeof loggerFactory> | undefined;
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* The app logger of ${logger.packageName} (D-0151) from options.logging: src/main.ts passes it to
|
|
92
|
+
* new Application({ logger }), so it writes from the first setup record. Created once.
|
|
93
|
+
*/
|
|
94
|
+
export function createLogger(options: Readonly<Record<string, unknown>>): Logger | undefined {
|
|
95
|
+
appLogger ??= loggerFactory({ options });
|
|
96
|
+
return appLogger;
|
|
97
|
+
}`
|
|
98
|
+
: `/**
|
|
99
|
+
* No logger plugin is installed (D-0151): undefined, so the Application uses core's console
|
|
100
|
+
* logger. npx dokebi add @dokebi/logger makes this return its logger.
|
|
101
|
+
*/
|
|
102
|
+
export function createLogger(_options: Readonly<Record<string, unknown>>): Logger | undefined {
|
|
103
|
+
return undefined;
|
|
104
|
+
}`;
|
|
105
|
+
// D-0155: the settings sections of the application, checked at start like plugin options.
|
|
106
|
+
settings.forEach((section, index) => {
|
|
107
|
+
imports.push(`import settings${index} from ${JSON.stringify(`./shared/settings/${section}.ts`)};`);
|
|
108
|
+
});
|
|
109
|
+
const settingsCode = settings.length
|
|
110
|
+
? ` // The settings of the application (src/shared/settings): options.<section>, checked here.
|
|
111
|
+
${settings.map((_section, index) => ` app.register(settings${index}.plugin({ app: app.view }));`).join('\n')}
|
|
112
|
+
`
|
|
113
|
+
: '';
|
|
68
114
|
return `// Generated by dokebi. Change dokebi.yaml options or plugin entry files instead of this file.
|
|
69
|
-
import type { Application, Plugin } from '@dokebi/core';
|
|
115
|
+
import type { Application, Logger, Plugin } from '@dokebi/core';
|
|
70
116
|
${imports.join('\n')}
|
|
71
117
|
|
|
72
118
|
const MANIFESTS: readonly {
|
|
@@ -76,13 +122,15 @@ const MANIFESTS: readonly {
|
|
|
76
122
|
entry: boolean;
|
|
77
123
|
}[] = ${JSON.stringify(manifests)};
|
|
78
124
|
|
|
125
|
+
${loggerCode}
|
|
126
|
+
|
|
79
127
|
/**
|
|
80
128
|
* Creates and registers the installed plugins. Each factory checks its options.<plugin> section
|
|
81
129
|
* of dokebi.yaml with the plugin's own declaration; an invalid value fails here with
|
|
82
130
|
* INVALID_PLUGIN_OPTIONS (the plugin, the option paths and a fix).
|
|
83
131
|
*/
|
|
84
132
|
export async function registerPlugins(app: Application): Promise<void> {
|
|
85
|
-
const options: Record<string, unknown> = app.config;
|
|
133
|
+
${settingsCode} const options: Record<string, unknown> = app.config;
|
|
86
134
|
const optionsOf = (id: string): unknown =>
|
|
87
135
|
Object.hasOwn(options, id) && options[id] !== null ? options[id] : {};
|
|
88
136
|
const plugins: Plugin<unknown>[] = [
|
|
@@ -116,12 +164,53 @@ try {
|
|
|
116
164
|
await app.stop();
|
|
117
165
|
}
|
|
118
166
|
`;
|
|
119
|
-
|
|
167
|
+
/**
|
|
168
|
+
* The long-running entry point before D-0151 (core's console logger only). `add` of a logger
|
|
169
|
+
* plugin rewrites an unchanged copy to MAIN_SOURCE; migrate still recognizes it.
|
|
170
|
+
*/
|
|
171
|
+
export const MAIN_SOURCE_V2 = `import { resolve } from 'node:path';
|
|
172
|
+
import { fileURLToPath } from 'node:url';
|
|
173
|
+
import { Application, loadConfig } from '@dokebi/core';
|
|
120
174
|
import { registerPlugins } from './dokebi.generated.ts';
|
|
121
175
|
|
|
122
|
-
const
|
|
176
|
+
const configPath = resolve(process.env.DOKEBI_CONFIG ?? fileURLToPath(new URL('../dokebi.yaml', import.meta.url)));
|
|
177
|
+
process.env.DOKEBI_CONFIG = configPath;
|
|
178
|
+
const document = await loadConfig(configPath);
|
|
123
179
|
const app = new Application({ config: document.options });
|
|
124
180
|
await registerPlugins(app);
|
|
181
|
+
app.logger.log('info', 'Dokebi application started');
|
|
182
|
+
|
|
183
|
+
// The app runs until SIGINT/SIGTERM (servers, consumers) or until nothing is left to run
|
|
184
|
+
// (one-off work), then drains and cleans up once. A second Ctrl+C exits immediately.
|
|
185
|
+
let stopping: Promise<void> | undefined;
|
|
186
|
+
const stop = (reason: string) =>
|
|
187
|
+
(stopping ??= app.stop().then(
|
|
188
|
+
() => app.logger.log('info', 'Dokebi application stopped', { reason }),
|
|
189
|
+
() => {
|
|
190
|
+
process.exitCode = 1;
|
|
191
|
+
app.logger.log('error', 'Dokebi application did not stop cleanly', { reason });
|
|
192
|
+
},
|
|
193
|
+
));
|
|
194
|
+
process.once('SIGINT', () => void stop('SIGINT'));
|
|
195
|
+
process.once('SIGTERM', () => void stop('SIGTERM'));
|
|
196
|
+
process.once('beforeExit', () => void stop('idle'));
|
|
197
|
+
`;
|
|
198
|
+
/**
|
|
199
|
+
* The entry point of D-0151 before D-0154 (no configFile: the app could not read dokebi.yaml
|
|
200
|
+
* again). `add` of a logger plugin rewrites an unchanged copy to MAIN_SOURCE; migrate still
|
|
201
|
+
* recognizes it.
|
|
202
|
+
*/
|
|
203
|
+
export const MAIN_SOURCE_V3 = `import { resolve } from 'node:path';
|
|
204
|
+
import { fileURLToPath } from 'node:url';
|
|
205
|
+
import { Application, loadConfig } from '@dokebi/core';
|
|
206
|
+
import { createLogger, registerPlugins } from './dokebi.generated.ts';
|
|
207
|
+
|
|
208
|
+
const configPath = resolve(process.env.DOKEBI_CONFIG ?? fileURLToPath(new URL('../dokebi.yaml', import.meta.url)));
|
|
209
|
+
process.env.DOKEBI_CONFIG = configPath;
|
|
210
|
+
const document = await loadConfig(configPath);
|
|
211
|
+
// The app logger: @dokebi/logger (options.logging targets) when installed, else core's console logger.
|
|
212
|
+
const app = new Application({ config: document.options, logger: createLogger(document.options) });
|
|
213
|
+
await registerPlugins(app);
|
|
125
214
|
await app.start();
|
|
126
215
|
app.logger.log('info', 'Dokebi application started');
|
|
127
216
|
|
|
@@ -140,6 +229,99 @@ process.once('SIGINT', () => void stop('SIGINT'));
|
|
|
140
229
|
process.once('SIGTERM', () => void stop('SIGTERM'));
|
|
141
230
|
process.once('beforeExit', () => void stop('idle'));
|
|
142
231
|
`;
|
|
232
|
+
/**
|
|
233
|
+
* The entry point of D-0154 before the IPC start message (AI-M43): `start` knew the app was up
|
|
234
|
+
* only from the info line, which logging.level warn hides. migrate still recognizes it.
|
|
235
|
+
*/
|
|
236
|
+
export const MAIN_SOURCE_V4 = `import { resolve } from 'node:path';
|
|
237
|
+
import { fileURLToPath } from 'node:url';
|
|
238
|
+
import { Application, loadConfig } from '@dokebi/core';
|
|
239
|
+
import { createLogger, registerPlugins } from './dokebi.generated.ts';
|
|
240
|
+
|
|
241
|
+
const configPath = resolve(process.env.DOKEBI_CONFIG ?? fileURLToPath(new URL('../dokebi.yaml', import.meta.url)));
|
|
242
|
+
process.env.DOKEBI_CONFIG = configPath;
|
|
243
|
+
const document = await loadConfig(configPath);
|
|
244
|
+
// The app logger: @dokebi/logger (options.logging targets) when installed, else core's console logger.
|
|
245
|
+
// configFile: the app reads dokebi.yaml again when it changes (options marked reloadable apply live).
|
|
246
|
+
const app = new Application({
|
|
247
|
+
config: document.options,
|
|
248
|
+
configFile: configPath,
|
|
249
|
+
logger: createLogger(document.options),
|
|
250
|
+
});
|
|
251
|
+
await registerPlugins(app);
|
|
252
|
+
await app.start();
|
|
253
|
+
app.logger.log('info', 'Dokebi application started');
|
|
254
|
+
|
|
255
|
+
// The app runs until SIGINT/SIGTERM (servers, consumers) or until nothing is left to run
|
|
256
|
+
// (one-off work), then drains and cleans up once. A second Ctrl+C exits immediately.
|
|
257
|
+
let stopping: Promise<void> | undefined;
|
|
258
|
+
const stop = (reason: string) =>
|
|
259
|
+
(stopping ??= app.stop().then(
|
|
260
|
+
() => app.logger.log('info', 'Dokebi application stopped', { reason }),
|
|
261
|
+
() => {
|
|
262
|
+
process.exitCode = 1;
|
|
263
|
+
app.logger.log('error', 'Dokebi application did not stop cleanly', { reason });
|
|
264
|
+
},
|
|
265
|
+
));
|
|
266
|
+
process.once('SIGINT', () => void stop('SIGINT'));
|
|
267
|
+
process.once('SIGTERM', () => void stop('SIGTERM'));
|
|
268
|
+
process.once('beforeExit', () => void stop('idle'));
|
|
269
|
+
`;
|
|
270
|
+
/**
|
|
271
|
+
* The entry point of the IPC start message before an empty DOKEBI_CONFIG counted as unset
|
|
272
|
+
* (R4CB-1: `DOKEBI_CONFIG= node src/main.ts` resolved '' to the working folder and failed with
|
|
273
|
+
* CONFIG_READ_FAILED, while every CLI command reads dokebi.yaml then, D-0154 D4). migrate still
|
|
274
|
+
* recognizes it; doctor names a main.ts with this line (TEMPLATE_FIX_MISSING, template-fixes.ts).
|
|
275
|
+
*/
|
|
276
|
+
export const MAIN_SOURCE_V5 = `import { resolve } from 'node:path';
|
|
277
|
+
import { fileURLToPath } from 'node:url';
|
|
278
|
+
import { Application, loadConfig } from '@dokebi/core';
|
|
279
|
+
import { createLogger, registerPlugins } from './dokebi.generated.ts';
|
|
280
|
+
|
|
281
|
+
const configPath = resolve(process.env.DOKEBI_CONFIG ?? fileURLToPath(new URL('../dokebi.yaml', import.meta.url)));
|
|
282
|
+
process.env.DOKEBI_CONFIG = configPath;
|
|
283
|
+
const document = await loadConfig(configPath);
|
|
284
|
+
// The app logger: @dokebi/logger (options.logging targets) when installed, else core's console logger.
|
|
285
|
+
// configFile: the app reads dokebi.yaml again when it changes (options marked reloadable apply live).
|
|
286
|
+
const app = new Application({
|
|
287
|
+
config: document.options,
|
|
288
|
+
configFile: configPath,
|
|
289
|
+
logger: createLogger(document.options),
|
|
290
|
+
});
|
|
291
|
+
await registerPlugins(app);
|
|
292
|
+
await app.start();
|
|
293
|
+
app.logger.log('info', 'Dokebi application started');
|
|
294
|
+
// dokebi start and verify learn that the app is up from this message as well, so a
|
|
295
|
+
// logging.level above info (which hides the line above) does not look like a failed start.
|
|
296
|
+
if (process.send) {
|
|
297
|
+
process.send({ dokebi: 'started' });
|
|
298
|
+
process.channel?.unref();
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// The app runs until SIGINT/SIGTERM (servers, consumers) or until nothing is left to run
|
|
302
|
+
// (one-off work), then drains and cleans up once. A second Ctrl+C exits immediately.
|
|
303
|
+
let stopping: Promise<void> | undefined;
|
|
304
|
+
const stop = (reason: string) =>
|
|
305
|
+
(stopping ??= app.stop().then(
|
|
306
|
+
() => app.logger.log('info', 'Dokebi application stopped', { reason }),
|
|
307
|
+
() => {
|
|
308
|
+
process.exitCode = 1;
|
|
309
|
+
app.logger.log('error', 'Dokebi application did not stop cleanly', { reason });
|
|
310
|
+
},
|
|
311
|
+
));
|
|
312
|
+
process.once('SIGINT', () => void stop('SIGINT'));
|
|
313
|
+
process.once('SIGTERM', () => void stop('SIGTERM'));
|
|
314
|
+
process.once('beforeExit', () => void stop('idle'));
|
|
315
|
+
`;
|
|
316
|
+
/**
|
|
317
|
+
* src/main.ts of a new project. D-0151: the logger comes from the generated registration
|
|
318
|
+
* (@dokebi/logger when installed, else undefined: core's console logger), so it is in place
|
|
319
|
+
* before the first plugin logs. D-0154: configFile lets the app read dokebi.yaml again while it
|
|
320
|
+
* runs (a change of the file, SIGHUP, npx dokebi reload); options marked reloadable apply
|
|
321
|
+
* without a restart. An empty DOKEBI_CONFIG means dokebi.yaml, as for every CLI command (D-0154
|
|
322
|
+
* D4; `??` took '' for a path and read the working folder).
|
|
323
|
+
*/
|
|
324
|
+
export const MAIN_SOURCE = MAIN_SOURCE_V5.replace('resolve(process.env.DOKEBI_CONFIG ?? fileURLToPath(', 'resolve(process.env.DOKEBI_CONFIG || fileURLToPath(');
|
|
143
325
|
/**
|
|
144
326
|
* tsconfig.json written by init. `node src/main.ts` strips types without compiling, so the
|
|
145
327
|
* editor and `tsc` should reject syntax Node cannot run (erasableSyntaxOnly) and need explicit
|
|
@@ -158,6 +340,14 @@ export const PROJECT_DEV_DEPENDENCIES = {
|
|
|
158
340
|
};
|
|
159
341
|
/** `npm run typecheck`: the checks of the generated tsconfig.json (it already sets noEmit). */
|
|
160
342
|
export const TYPECHECK_SCRIPT = 'tsc -p tsconfig.json --noEmit';
|
|
343
|
+
/**
|
|
344
|
+
* `npm test` (D-0077 H): node:test over the project's `*.test.ts` files under tests (Node strips
|
|
345
|
+
* their types; none exist at first, which passes). `dokebi verify` runs it with a result per
|
|
346
|
+
* test. The pattern is quoted so node expands it the same way on every shell. It covers the
|
|
347
|
+
* maintained test folders tests/unit and tests/integration; helpers in tests/support are not
|
|
348
|
+
* named *.test.ts (D-0092 §3, the "Files and folders" rule of capability-guide.ts).
|
|
349
|
+
*/
|
|
350
|
+
export const TEST_SCRIPT = 'node --test "tests/**/*.test.ts"';
|
|
161
351
|
export const TSCONFIG_SOURCE = `${JSON.stringify({
|
|
162
352
|
compilerOptions: {
|
|
163
353
|
target: 'ES2023',
|
|
@@ -174,5 +364,5 @@ export const TSCONFIG_SOURCE = `${JSON.stringify({
|
|
|
174
364
|
// TypeScript 6+ no longer loads every @types package; init installs @types/node.
|
|
175
365
|
types: ['node'],
|
|
176
366
|
},
|
|
177
|
-
include: ['src/**/*.ts'],
|
|
367
|
+
include: ['src/**/*.ts', 'tests/**/*.ts'],
|
|
178
368
|
}, null, 2)}\n`;
|
package/dist/guide.d.ts
CHANGED
|
@@ -23,7 +23,7 @@ export interface Guide {
|
|
|
23
23
|
readonly text: string;
|
|
24
24
|
}
|
|
25
25
|
/** The short pointer printed at the top of `dokebi --help`. */
|
|
26
|
-
export declare const HELP_POINTER
|
|
26
|
+
export declare const HELP_POINTER: string;
|
|
27
27
|
export declare const GUIDE_STEPS: readonly GuideStep[];
|
|
28
28
|
export declare const GUIDE_RULES: readonly string[];
|
|
29
29
|
export declare function buildGuide(catalog: Catalog): Guide;
|
package/dist/guide.js
CHANGED
|
@@ -1,7 +1,13 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { ANSWER_SOURCES, ASK_AND_WAIT, BROWSER_APPS, INTEGRATION_RULES, } from './capability-guide.js';
|
|
2
|
+
import { COMMANDS, dryRunList } from './commands.js';
|
|
2
3
|
import { CLI_VERSION } from './output.js';
|
|
3
4
|
/** The short pointer printed at the top of `dokebi --help`. */
|
|
4
|
-
export const HELP_POINTER =
|
|
5
|
+
export const HELP_POINTER = `AI and automation: read \`dokebi guide\` (--json) first. Use choices the user already supplied; ask only unresolved recipe choices and wait for those answers; finish with \`dokebi verify\` and say done only when it is ok. Use --json except for interactive dev, logs and start --foreground; only these take --dry-run: ${dryRunList()}. Outside a project run npx -y @dokebi/cli (inside, npx dokebi uses the project copy); the npm package named "dokebi" is not this CLI\n`;
|
|
6
|
+
/**
|
|
7
|
+
* The relay rule of verify's tellUser lines, in the verify step and the rules alike (one text:
|
|
8
|
+
* the two had been written apart and already said it in different words, D-0110 §5).
|
|
9
|
+
*/
|
|
10
|
+
const RELAY_TELL_USER = "Your final message to the user must include verify's tellUser lines: copy these lines unchanged (do not translate, reword or summarize them); you may add your own summary after them";
|
|
5
11
|
export const GUIDE_STEPS = [
|
|
6
12
|
{
|
|
7
13
|
command: 'npx -y @dokebi/cli init <folder> && cd <folder>',
|
|
@@ -9,47 +15,58 @@ export const GUIDE_STEPS = [
|
|
|
9
15
|
},
|
|
10
16
|
{
|
|
11
17
|
command: 'npx dokebi recipe list --json',
|
|
12
|
-
why:
|
|
18
|
+
why: `Pick the recipe that fits the request: a service people use in a browser → site (or blog for posts), an API the user asked for, whose clients are other programs → api (no browser screens). ${BROWSER_APPS} If none fits, use plugins search`,
|
|
13
19
|
},
|
|
14
20
|
{
|
|
15
21
|
command: 'npx dokebi recipe <name> --json',
|
|
16
|
-
why:
|
|
22
|
+
why: `Without answers it installs nothing and returns questions[] (waitForUser: true). Pass the choices the user already gave as flags; show the user every question still in missing[] with its options, pros/cons and default, then **end your turn**. ${ASK_AND_WAIT}`,
|
|
17
23
|
},
|
|
18
24
|
{
|
|
19
|
-
command: 'npx dokebi recipe <name> --view <v> --db <d> --dev-infra <i> --admin <a> --answers-from user --dry-run --json',
|
|
25
|
+
command: 'npx dokebi recipe <name> --view <v> --db <d> --dev-infra <i> --admin <a> --mail <m> --answers-from user --dry-run --json',
|
|
20
26
|
why: 'Only after the user answered: see the plan (plan.steps) and the preflight for those answers. Ask the user about a preflight error (never switch the database or a port behind their back)',
|
|
21
27
|
},
|
|
22
28
|
{
|
|
23
|
-
command: 'npx dokebi recipe <name> --view <v> --db <d> --dev-infra <i> --admin <a> --answers-from user --json',
|
|
24
|
-
why:
|
|
29
|
+
command: 'npx dokebi recipe <name> --view <v> --db <d> --dev-infra <i> --admin <a> --mail <m> --answers-from user --json',
|
|
30
|
+
why: `Apply the plan. Applying needs the source of the answers: --answers-from user (the user answered) or --defaults (the user said to decide); without one it is ANSWERS_SOURCE_REQUIRED. ${ANSWER_SOURCES} Tell the user the report of the result. Each step runs an existing transactional command; a failure stops with completedSteps, and the same command resumes`,
|
|
25
31
|
},
|
|
26
32
|
{
|
|
27
33
|
command: 'npx dokebi plugins doc <id> --json',
|
|
28
34
|
why: 'Read the AI.md of the installed version of every installed plugin (AGENTS.md lists the paths under "Installed packages")',
|
|
29
35
|
},
|
|
36
|
+
{
|
|
37
|
+
command: 'npx dokebi plugins search process.cluster --json',
|
|
38
|
+
why: 'Before implementing the runtime, confirm whether the user wants a single application process or a process cluster, and the worker count. No recipe configures this. App processes (@dokebi/cluster), durable jobs (@dokebi/job) and CPU threads (@dokebi/worker) are different choices. If cluster is selected, install the library using its catalog install command and read node_modules/@dokebi/cluster/AI.md; the blog recipe has no --workers flag. Follow its complete parent/worker example and shared-state prerequisites',
|
|
39
|
+
},
|
|
30
40
|
{
|
|
31
41
|
command: '(write the app code) src/apps/<app>, src/shared/modules/<module>',
|
|
32
42
|
why: "Follow the nextSteps of the recipe result: attach the module to the app and write the features. Build what Dokebi does not have (absent) in app code, as its instead says. Errors and forms (D-0074): refuse input with throw validationError({ field: 'message' }) from @dokebi/web (422 with errors by field, like a refused schema); Inertia form submits pass only (the props they change) and show form.errors; EJS forms show data.errors.<field> and data.old.<field> (with @dokebi/session a refused form comes back to its page, 303); EJS apps may add views/<template>/errors/404.ejs, errors/422.ejs and errors/error.ejs",
|
|
33
43
|
},
|
|
34
44
|
{
|
|
35
45
|
command: 'npx dokebi check --json',
|
|
36
|
-
why: 'Convention check while you write code (empty catch, promise without await, syntax Node cannot strip, private @dokebi paths, missing packages, edited generated files, secrets that would be committed). Every problem has file, line and fix',
|
|
46
|
+
why: 'Convention check while you write code (empty catch, promise without await, syntax Node cannot strip, private @dokebi paths, missing packages, edited generated files, secrets that would be committed; with @dokebi/auth a password compared or hashed by hand, with @dokebi/csrf a post form without the token or a csrf: false route that verifies nothing). Every problem has file, line and fix',
|
|
37
47
|
},
|
|
38
48
|
{
|
|
39
49
|
command: 'npx dokebi verify --json',
|
|
40
|
-
why:
|
|
50
|
+
why: `doctor, check, typecheck, build, the project's tests (npm test; a result per node:test test, with the [ID] of each name), start, HTTP checks and stop. Tell the user it is done only when ok: true. ${RELAY_TELL_USER}. Those lines are the recipe's choices with who made them, defaults with their reasons, and notices such as how the forms send the CSRF token and where the admin login and its first password are; a notice that states a fact of the project says what verify found when it ran (D-0137)`,
|
|
41
51
|
},
|
|
42
52
|
];
|
|
43
53
|
export const GUIDE_RULES = [
|
|
44
|
-
'
|
|
45
|
-
'Use
|
|
46
|
-
|
|
47
|
-
'
|
|
48
|
-
'Never
|
|
49
|
-
'
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
'
|
|
54
|
+
'Runtime layout: src/main.ts owns process signals; a cluster worker factory belongs in src/infra/runtime/application.ts. Store the selected process count in dokebi.yaml options.application-runtime.workers. Use ClusterRunner even for one worker once the project has chosen cluster mode. Resolve DOKEBI_CONFIG once to an absolute YAML path in the parent and inherit that same path in workers; never send credentials through IPC or introduce .env. Configure shared session/auth/rate-limit stores and scheduler/WebSocket coordination before claiming multiple workers work. Verify the effective worker count, HTTP readiness and graceful group-signal shutdown, not just typecheck',
|
|
55
|
+
'Use choices already supplied by the user. For unresolved choices such as the view, database, development infrastructure, mail or admin area, ask the applicable questions[] and wait. Run no dependent recipe, add or config before those answers (never "I will go on with the defaults while you choose")',
|
|
56
|
+
`Use --defaults or --ai-chose only when the user left the choice to you ("you decide"), and report the chosen values with their reasons afterwards. When the user answered, use --answers-from user. ${ANSWER_SOURCES}`,
|
|
57
|
+
'Mail (D-0078): ask --mail none|mailpit|smtp|ses. Mailpit starts local mail and a durable queue; SMTP/SES require existing settings in dokebi.yaml. Inspect templates with describe and render preview.json with npx dokebi mail preview <name> --json. In requests use mail.queue, with tokens and links in sealed, never data',
|
|
58
|
+
'Never invent a plugin, a flag or an API. Use plugins search and the catalog-derived absent list below; read the installed contract for each supported capability and its limits',
|
|
59
|
+
'Never install or run a package outside @dokebi/ (THIRD_PARTY_PACKAGE) without the permission of the user; the formatter and the headless browser runner the rules ask for are permitted devDependencies, named in the final report. The tools a dokebi command installs as part of its scaffold (app add --inertia: vite, @vitejs/plugin-vue, vue, @inertiajs/vue3, vue-tsc, @typescript/typescript6) belong to the framework and need no permission',
|
|
60
|
+
"Secrets live in dokebi.yaml (0600, kept out of Git), never in dokebi.example.yaml: add writes the ones a plugin declares (generatedSecrets names them), npx dokebi config secret <id>.<path> generates one later, and a value the user supplies (such as a database URL with a password) is written with your file editor, npx dokebi config set or by the user. An environment variable is only an optional production override where a plugin documents one. dokebi.yaml is the developer's file (D-0165): read and edit it directly, secrets included; npx dokebi config show [<id>[.<path>]] gives it with schema defaults (secrets as <secret: set>, for output you share); to change a secret use npx dokebi config secret <id>.<path> --rotate. What stays out of Git: dokebi.yaml itself and secrets in dokebi.example.yaml",
|
|
61
|
+
'To see what an app has (routes with their login rule, pages, jobs, schedules, error codes, option keys and environment variable names; never a value), run npx dokebi describe --json: it loads the app without serving or connecting (D-0077). Before you write a migration run npx dokebi describe --with-db --json to see the current tables; npx dokebi describe --openapi gives an OpenAPI 3.1 document per app; npx dokebi drift --expected <file> --json compares the databases (tables, columns, column comments) with a file of the describe shape, such as a design',
|
|
62
|
+
'Preview commands that support --dry-run with --dry-run --json first; consult command help for the supported flags. Never blindly re-run a failure, and never delete the lock or a backup',
|
|
63
|
+
'CLI-owned src/dokebi.generated.ts, .dokebi/state.json, .dokebi/recipe.json and the installed/capabilities marker blocks of AGENTS.md must not be edited by hand. Commands also update YAML, package metadata and installation records transactionally; consult command help and ownership metadata before changing a file. The dokebi:project block of AGENTS.md holds the rules of this project: read and follow them; the CLI never changes that block. Scaffold source files declared owner: user (the shared layout src/shared/views/layouts/main.ejs, entry files, app and module files) are user code: maintain it in place unless the user requests restructuring; then update its imports, resources and registration together instead of keeping a duplicate implementation',
|
|
64
|
+
`Never say done before verify returns ok: true. ${RELAY_TELL_USER}`,
|
|
65
|
+
"Login and permissions (D-0076): use @dokebi/auth (the blog recipe's --admin login sets it up; ask the user about the admin area first). Implement its extension class on the app's own user table, sign in with request.auth.attempt({ identity, password }), protect routes with config: { auth: 'required' } or { can: '<ability>' }, and show one sentence for every failed login. Never compare or hash passwords yourself: passwords.hash for sign-up and password changes, passwords.verify to check one",
|
|
66
|
+
"Roles (D-0077): when the design has a role table, return roles: [row.role] from the auth extension's credentials and declare config: { roles: ['admin'] } on the route (any one role passes; with can both must pass) instead of hand-written role ifs in handlers",
|
|
67
|
+
"CSRF (D-0076): @dokebi/csrf checks every POST, PUT, PATCH and DELETE (the blog recipe installs it). EJS: <%- data.csrf.field %> inside every <form method=\"post\"> (on a sealed domain @dokebi/seal/forms, loaded by <%- data.seal?.head ?? '' %> in the layout <head>, carries it inside the sealed body, and in the x-csrf-token header for a multipart form with a file; sealed forms are form bodies, so dokebi-web.formBody: true; a script submits with form.requestSubmit() or submitSealedForm(form), never form.submit(), which goes out plain and gets 415); Inertia navigation/forms: its HTTP client supplies X-XSRF-TOKEN. Application API calls use sealedFetch, which sends the current XSRF-TOKEN cookie as X-XSRF-TOKEN on every unsafe same-origin request by itself (@dokebi/seal 0.4.2; no adapter of your own). Never turn the check off or exempt a route to make a test pass; only a webhook that verifies its own signature, an @dokebi/auth Bearer-only route (auth: { via: 'token' }) or the token refresh route (it calls tokens.refresh) takes config: { csrf: false }",
|
|
68
|
+
'Errors (D-0074): Inertia, fetch/XHR, Bearer and JSON-body requests get JSON errors and only plain browser pages get HTML (an EJS form post sealed by @dokebi/seal/forms, request header x-dokebi-form: 1, counts as an HTML form post), so read errors from the JSON. Refuse input with validationError({ field: message }) (422 VALIDATION_FAILED, fields and errors), never a hand-made 422; give every Inertia form submit an only option; an EJS write form shows data.errors.<field> and data.old.<field> (add @dokebi/session so a refused form comes back to its page)',
|
|
69
|
+
"App error codes (D-0077): declare them once in src/shared/errors.ts with export const errors = defineErrors({ CODE: { status, message, hint? } }) from @dokebi/web and throw errors.CODE({ data }); add a new code to the list first, never invent one inline (new DokebiError('X', …), send({ error: 'X' }): check warns unknown-error-code). A route the user's design names gets config: { id: '<its id>' }",
|
|
53
70
|
];
|
|
54
71
|
export function buildGuide(catalog) {
|
|
55
72
|
const recipes = catalog.recipes.map((recipe) => ({
|
|
@@ -58,15 +75,19 @@ export function buildGuide(catalog) {
|
|
|
58
75
|
questions: recipe.questions,
|
|
59
76
|
}));
|
|
60
77
|
const absent = catalog.absent.map((entry) => entry.capability);
|
|
78
|
+
const rules = [
|
|
79
|
+
...GUIDE_RULES,
|
|
80
|
+
...INTEGRATION_RULES.filter(Boolean).map((rule) => rule.replace(/^- /, '')),
|
|
81
|
+
];
|
|
61
82
|
const text = [
|
|
62
83
|
`# Dokebi CLI ${CLI_VERSION} — AI first page`,
|
|
63
84
|
'',
|
|
64
|
-
'When the user asks in one line, such as "make me a
|
|
85
|
+
'When the user asks in one line, such as "make me a web service with the Dokebi framework (@dokebi/cli)", follow these steps in order.',
|
|
65
86
|
'',
|
|
66
87
|
...GUIDE_STEPS.map((step, index) => `${index + 1}. ${step.command}\n ${step.why}`),
|
|
67
88
|
'',
|
|
68
89
|
'## Rules',
|
|
69
|
-
...
|
|
90
|
+
...rules.map((rule) => `- ${rule}`),
|
|
70
91
|
'',
|
|
71
92
|
'## Recipes',
|
|
72
93
|
...recipes.map((recipe) => `- ${recipe.name}: ${recipe.summary} (questions: ${recipe.questions.join(', ')})`),
|
|
@@ -81,7 +102,7 @@ export function buildGuide(catalog) {
|
|
|
81
102
|
return {
|
|
82
103
|
cliVersion: CLI_VERSION,
|
|
83
104
|
steps: GUIDE_STEPS,
|
|
84
|
-
rules
|
|
105
|
+
rules,
|
|
85
106
|
recipes,
|
|
86
107
|
absent,
|
|
87
108
|
commands: COMMANDS.map(({ usage, summary }) => ({ usage, summary })),
|
package/dist/install-module.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export type InstallPhase = 'planInstall' | 'planUpgrade' | 'check';
|
|
1
|
+
export type InstallPhase = 'planInstall' | 'planUpgrade' | 'check' | 'checkOptions';
|
|
2
2
|
export interface InstallModuleContext {
|
|
3
3
|
readonly options: Readonly<Record<string, unknown>>;
|
|
4
4
|
readonly plugins: readonly {
|
|
@@ -10,7 +10,16 @@ export interface InstallModuleContext {
|
|
|
10
10
|
}
|
|
11
11
|
export declare class InstallPlanningError extends Error {
|
|
12
12
|
readonly code: string;
|
|
13
|
-
|
|
13
|
+
/**
|
|
14
|
+
* What the module said: the message of the error it threw, or the end of its stderr when the
|
|
15
|
+
* process failed (at most DETAIL_CHARS, `user:password@` and tokens masked). Without it a
|
|
16
|
+
* failing planInstall was only INSTALL_MODULE_FAILED, and its author could not see why.
|
|
17
|
+
*/
|
|
18
|
+
readonly detail: string | undefined;
|
|
19
|
+
constructor(code: string, options?: {
|
|
20
|
+
readonly cause?: unknown;
|
|
21
|
+
readonly detail?: string | undefined;
|
|
22
|
+
});
|
|
14
23
|
}
|
|
15
24
|
/** Returned when the module does not export the requested phase. */
|
|
16
25
|
export declare const PHASE_MISSING: unique symbol;
|
|
@@ -28,4 +37,13 @@ export declare function installReadRoots(packageDirectory: string): Promise<stri
|
|
|
28
37
|
* (at their real and link paths), cannot write files or spawn processes, and receives no
|
|
29
38
|
* parent environment variables. Node 24 does not restrict network access.
|
|
30
39
|
*/
|
|
40
|
+
/**
|
|
41
|
+
* The `--allow-fs-read` flags of the restricted child. Node 24.15's permission model denies a
|
|
42
|
+
* granted folder when a sibling folder whose name starts with it is granted too (with
|
|
43
|
+
* `node_modules/@dokebi/web` and `node_modules/@dokebi/websocket`, `realpath` of the first is
|
|
44
|
+
* ERR_ACCESS_DENIED, so `import('@dokebi/web')` fails: seen with D-0162's options check of
|
|
45
|
+
* dokebi-websocket). Such a folder is granted as `<folder>*`, which Node handles; it adds only
|
|
46
|
+
* the siblings that start with the same name.
|
|
47
|
+
*/
|
|
48
|
+
export declare function readPermissions(roots: readonly string[]): string[];
|
|
31
49
|
export declare function runInstallModule(modulePath: string, phase: InstallPhase, context: InstallModuleContext, readRoots?: readonly string[]): Promise<unknown>;
|
package/dist/install-module.js
CHANGED
|
@@ -3,19 +3,33 @@ import { readFile, realpath, stat } from 'node:fs/promises';
|
|
|
3
3
|
import { basename, dirname, join, sep } from 'node:path';
|
|
4
4
|
import { pathToFileURL } from 'node:url';
|
|
5
5
|
import { debug, debugEnabled, describeError, isMissing } from './diagnostics.js';
|
|
6
|
+
import { redactNpmOutput } from './npm-client.js';
|
|
6
7
|
export class InstallPlanningError extends Error {
|
|
7
8
|
code;
|
|
8
|
-
|
|
9
|
-
|
|
9
|
+
/**
|
|
10
|
+
* What the module said: the message of the error it threw, or the end of its stderr when the
|
|
11
|
+
* process failed (at most DETAIL_CHARS, `user:password@` and tokens masked). Without it a
|
|
12
|
+
* failing planInstall was only INSTALL_MODULE_FAILED, and its author could not see why.
|
|
13
|
+
*/
|
|
14
|
+
detail;
|
|
15
|
+
constructor(code, options) {
|
|
16
|
+
super(`Plugin installation planning failed (${code}).`, options);
|
|
10
17
|
this.code = code;
|
|
11
18
|
this.name = 'InstallPlanningError';
|
|
19
|
+
// Masked before it is cut (R4CB-7): a cut inside a password left its first part without the
|
|
20
|
+
// `@` the mask looks for.
|
|
21
|
+
const detail = options?.detail ? redactNpmOutput(options.detail).trim() : undefined;
|
|
22
|
+
this.detail = detail ? detail.slice(0, DETAIL_CHARS) : undefined;
|
|
12
23
|
}
|
|
13
24
|
}
|
|
25
|
+
const DETAIL_CHARS = 2000;
|
|
26
|
+
/** The end of stderr kept for the detail: more than it shows, so the mask sees whole values. */
|
|
27
|
+
const STDERR_CHARS = DETAIL_CHARS * 2;
|
|
14
28
|
/** Returned when the module does not export the requested phase. */
|
|
15
29
|
export const PHASE_MISSING = Symbol('dokebi.install.phase-missing');
|
|
16
30
|
const MAX_BYTES = 1024 * 1024;
|
|
17
31
|
const TIMEOUT_MS = 5000;
|
|
18
|
-
const RUNNER = "\nconst finish = (value) => process.stdout.write(JSON.stringify(value), () => process.exit(0));\ntry {\n let input = '';\n for await (const chunk of process.stdin) input += chunk;\n const freeze = (value) => {\n if (value && typeof value === 'object') {for (const child of Object.values(value)) freeze(child);Object.freeze(value);}\n return value;\n };\n const phase = process.argv[2];\n const context = freeze({ ...JSON.parse(input), phase });\n const module = await import(process.argv[1]);\n if (typeof module[phase] !== 'function') finish({ok:true,missing:true});\n else {\n const plan = await module[phase](context);\n if(plan === undefined)finish({ok:false,code:'INSTALL_PLAN_INVALID'});\n else finish({ok:true,plan});\n }\n} catch(error) {\n const code = error && error.code === 'ERR_ACCESS_DENIED' ? 'INSTALL_PERMISSION_DENIED' : error && typeof error.code === 'string' && /^[A-Z][A-Z0-9_]{0,63}$/.test(error.code) ? error.code : 'INSTALL_MODULE_FAILED';\n finish({ok:false,code});\n}\n";
|
|
32
|
+
const RUNNER = "\nconst finish = (value) => process.stdout.write(JSON.stringify(value), () => process.exit(0));\ntry {\n let input = '';\n for await (const chunk of process.stdin) input += chunk;\n const freeze = (value) => {\n if (value && typeof value === 'object') {for (const child of Object.values(value)) freeze(child);Object.freeze(value);}\n return value;\n };\n const phase = process.argv[2];\n const context = freeze({ ...JSON.parse(input), phase });\n const failure = (error) => ({ok:false,code:error && error.code === 'ERR_ACCESS_DENIED' ? 'INSTALL_PERMISSION_DENIED' : error && typeof error.code === 'string' && /^[A-Z][A-Z0-9_]{0,63}$/.test(error.code) ? error.code : 'INSTALL_MODULE_FAILED',message:String(error && error.stack || error).slice(0, 4000)});\n // D-0162: one child checks the options of every listed plugin (context.entries: {url, options}) with\n // the main entry's createPlugin.checkOptions; each plugin's result or failure is its own.\n if (phase === 'checkOptions') {\n const results = [];\n for (const entry of context.entries) {\n try {\n const factory = (await import(entry.url)).createPlugin;\n results.push(typeof factory?.checkOptions === 'function' ? {ok:true,result:factory.checkOptions(entry.options)} : {ok:true,missing:true});\n } catch (error) { results.push(failure(error)); }\n }\n finish({ok:true,plan:results});\n } else {\n const module = await import(process.argv[1]);\n if (typeof module[phase] !== 'function') finish({ok:true,missing:true});\n else {\n const plan = await module[phase](context);\n if(plan === undefined)finish({ok:false,code:'INSTALL_PLAN_INVALID'});\n else finish({ok:true,plan});\n }\n }\n} catch(error) {\n const code = error && error.code === 'ERR_ACCESS_DENIED' ? 'INSTALL_PERMISSION_DENIED' : error && typeof error.code === 'string' && /^[A-Z][A-Z0-9_]{0,63}$/.test(error.code) ? error.code : 'INSTALL_MODULE_FAILED';\n finish({ok:false,code,message:String(error && error.stack || error).slice(0, 4000)});\n}\n";
|
|
19
33
|
const PACKAGE_NAME = /^(?:@[a-z0-9][a-z0-9._~-]*\/)?[a-z0-9][a-z0-9._~-]*$/i;
|
|
20
34
|
/** Packages an install module may read (its own and its dependencies, transitively). */
|
|
21
35
|
const MAX_READ_PACKAGES = 512;
|
|
@@ -108,6 +122,22 @@ export async function installReadRoots(packageDirectory) {
|
|
|
108
122
|
* (at their real and link paths), cannot write files or spawn processes, and receives no
|
|
109
123
|
* parent environment variables. Node 24 does not restrict network access.
|
|
110
124
|
*/
|
|
125
|
+
/**
|
|
126
|
+
* The `--allow-fs-read` flags of the restricted child. Node 24.15's permission model denies a
|
|
127
|
+
* granted folder when a sibling folder whose name starts with it is granted too (with
|
|
128
|
+
* `node_modules/@dokebi/web` and `node_modules/@dokebi/websocket`, `realpath` of the first is
|
|
129
|
+
* ERR_ACCESS_DENIED, so `import('@dokebi/web')` fails: seen with D-0162's options check of
|
|
130
|
+
* dokebi-websocket). Such a folder is granted as `<folder>*`, which Node handles; it adds only
|
|
131
|
+
* the siblings that start with the same name.
|
|
132
|
+
*/
|
|
133
|
+
export function readPermissions(roots) {
|
|
134
|
+
return roots.map((root) => {
|
|
135
|
+
const parent = dirname(root);
|
|
136
|
+
const name = basename(root);
|
|
137
|
+
const shadowed = roots.some((other) => other !== root && dirname(other) === parent && basename(other).startsWith(name));
|
|
138
|
+
return `--allow-fs-read=${root}${shadowed ? '*' : ''}`;
|
|
139
|
+
});
|
|
140
|
+
}
|
|
111
141
|
export async function runInstallModule(modulePath, phase, context, readRoots = [dirname(modulePath)]) {
|
|
112
142
|
const input = JSON.stringify(context);
|
|
113
143
|
if (Buffer.byteLength(input) > MAX_BYTES)
|
|
@@ -124,13 +154,13 @@ export async function runInstallModule(modulePath, phase, context, readRoots = [
|
|
|
124
154
|
roots = [...new Set(resolved.flat())];
|
|
125
155
|
moduleUrl = pathToFileURL(await realpath(modulePath)).href;
|
|
126
156
|
}
|
|
127
|
-
catch {
|
|
128
|
-
throw new InstallPlanningError('INSTALL_MODULE_MISSING');
|
|
157
|
+
catch (cause) {
|
|
158
|
+
throw new InstallPlanningError('INSTALL_MODULE_MISSING', { cause });
|
|
129
159
|
}
|
|
130
160
|
return new Promise((resolve, reject) => {
|
|
131
161
|
const child = spawn(process.execPath, [
|
|
132
162
|
'--permission',
|
|
133
|
-
...roots
|
|
163
|
+
...readPermissions(roots),
|
|
134
164
|
'--input-type=module',
|
|
135
165
|
'--eval',
|
|
136
166
|
RUNNER,
|
|
@@ -138,6 +168,7 @@ export async function runInstallModule(modulePath, phase, context, readRoots = [
|
|
|
138
168
|
phase,
|
|
139
169
|
], { stdio: ['pipe', 'pipe', 'pipe'], env: {}, cwd: roots[0] });
|
|
140
170
|
const chunks = [];
|
|
171
|
+
let stderr = '';
|
|
141
172
|
let size = 0;
|
|
142
173
|
let failure;
|
|
143
174
|
const stop = (code) => {
|
|
@@ -156,6 +187,8 @@ export async function runInstallModule(modulePath, phase, context, readRoots = [
|
|
|
156
187
|
size += chunk.length;
|
|
157
188
|
if (size > MAX_BYTES)
|
|
158
189
|
stop('INSTALL_OUTPUT_LIMIT');
|
|
190
|
+
else
|
|
191
|
+
stderr = (stderr + chunk.toString('utf8')).slice(-STDERR_CHARS);
|
|
159
192
|
});
|
|
160
193
|
// Both become the typed InstallPlanningError the caller receives; debug keeps the cause.
|
|
161
194
|
child.on('error', (error) => {
|
|
@@ -176,7 +209,9 @@ export async function runInstallModule(modulePath, phase, context, readRoots = [
|
|
|
176
209
|
return;
|
|
177
210
|
}
|
|
178
211
|
if (code !== 0) {
|
|
179
|
-
|
|
212
|
+
// The end of stderr, masked while whole (R4CB-7).
|
|
213
|
+
const tail = redactNpmOutput(stderr).trim().slice(-DETAIL_CHARS);
|
|
214
|
+
reject(new InstallPlanningError('INSTALL_PROCESS_FAILED', { detail: tail }));
|
|
180
215
|
return;
|
|
181
216
|
}
|
|
182
217
|
try {
|
|
@@ -195,7 +230,9 @@ export async function runInstallModule(modulePath, phase, context, readRoots = [
|
|
|
195
230
|
if (envelope.ok === false &&
|
|
196
231
|
typeof envelope.code === 'string' &&
|
|
197
232
|
/^[A-Z][A-Z0-9_]{0,63}$/.test(envelope.code)) {
|
|
198
|
-
reject(new InstallPlanningError(envelope.code
|
|
233
|
+
reject(new InstallPlanningError(envelope.code, {
|
|
234
|
+
detail: typeof envelope.message === 'string' ? envelope.message : undefined,
|
|
235
|
+
}));
|
|
199
236
|
return;
|
|
200
237
|
}
|
|
201
238
|
}
|